From 26ce083d13da27260d18b8b45120b2181c69af90 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 13:30:44 +0200 Subject: [PATCH 01/17] fix(Dropdown): let Space reach buttons and fields in menu Space preventDefault only on targets without native Space handling (links, menu itself); buttons and inputs activate/type again. --- .../dropdown-menu.directive.spec.ts | 34 +++++++++++++++++++ .../dropdown-menu/dropdown-menu.directive.ts | 7 +++- 2 files changed, 40 insertions(+), 1 deletion(-) 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 c92e74fd..e76da22f 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 @@ -117,3 +117,37 @@ describe('DropdownMenuDirective', () => { expect(document.activeElement).toEqual(itemRef.nativeElement); }); }); + +@Component({ + template: ` + + + + + `, + imports: [DropdownComponent, DropdownMenuDirective, DropdownItemDirective, DropdownToggleDirective] +}) +class SpaceTestComponent {} + +describe('DropdownMenuDirective Space', () => { + it('should prevent page scroll on Space only for targets without native Space handling', () => { + const fixture = TestBed.createComponent(SpaceTestComponent); + fixture.detectChanges(); + const menu = fixture.debugElement.query(By.directive(DropdownMenuDirective)).nativeElement; + const press = (target: HTMLElement, code: string) => { + const event = new KeyboardEvent('keydown', { code, bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event.defaultPrevented; + }; + + expect(press(menu.querySelector('#button'), 'Space')).toBe(false); + expect(press(menu.querySelector('#field'), 'Space')).toBe(false); + expect(press(menu.querySelector('#link'), 'Space')).toBe(true); + expect(press(menu, 'Space')).toBe(true); + expect(press(menu.querySelector('#button'), 'ArrowDown')).toBe(true); + }); +}); 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 4978320d..afde8593 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 @@ -82,7 +82,7 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { if (!this.visible()) { return; } - if (['Space', 'ArrowDown'].includes($event.code)) { + if ($event.code === 'ArrowDown' || ($event.code === 'Space' && !this.#handlesSpace($event.target))) { $event.preventDefault(); } this.#focusKeyManager.onKeydown($event); @@ -137,6 +137,11 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { .subscribe(); } + #handlesSpace(target: EventTarget | null): boolean { + const control = (target as Element | null)?.closest?.('button, input, select, textarea, [contenteditable]'); + return !!control && this.elementRef.nativeElement.contains(control); + } + private focusKeyManagerInit(): void { this.#focusKeyManager = new FocusKeyManager(this.dropdownItemsContent()) .withHomeAndEnd() From 557d01d7523c130ae3489929a8939d7fcf2dd907 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 13:29:52 +0200 Subject: [PATCH 02/17] fix(Dropdown): toggle aria-expanded follows visible from first render Read from dropdown visible() signal; visibleChange subscription in ngAfterViewInit missed initial emit. --- .../dropdown/dropdown.component.spec.ts | 24 +++++++++++++++++++ .../dropdown/dropdown/dropdown.component.ts | 16 +++---------- 2 files changed, 27 insertions(+), 13 deletions(-) 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 78bd6ab6..5e9c0b60 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 @@ -175,6 +175,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: ` 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..0aa4e497 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -46,7 +46,6 @@ export abstract class DropdownToken {} } }) export class DropdownToggleDirective implements AfterViewInit { - readonly #destroyRef = inject(DestroyRef); public readonly elementRef = inject(ElementRef); #dropdownService = inject(DropdownService); public dropdown = inject(DropdownToken, { optional: true }); @@ -88,7 +87,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(); @@ -105,17 +106,6 @@ export class DropdownToggleDirective implements AfterViewInit { 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(); - }); - } - } } } From 8f7420b2693147f41407be11722972ae3ac38eb9 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 13:43:01 +0200 Subject: [PATCH 03/17] fix(Dropdown): Enter on button or link item toggles menu once Native click already handles Enter on +
  • Link
  • +
  • Plain
  • + +
    + `, + imports: [DropdownComponent, DropdownMenuDirective, DropdownItemDirective] +}) +class EnterTestComponent { + readonly dropdown = viewChild.required(DropdownComponent); +} + +describe('DropdownItemDirective Enter', () => { + it('should toggle on Enter keyup only for items without a native Enter click', () => { + const fixture = TestBed.createComponent(EnterTestComponent); + fixture.detectChanges(); + const dropdown = fixture.componentInstance.dropdown(); + const keyup = (id: string) => { + fixture.nativeElement.querySelector(`#${id}`).dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + fixture.detectChanges(); + }; + + keyup('button'); + expect(dropdown.visible()).toBe(true); + keyup('link'); + expect(dropdown.visible()).toBe(true); + keyup('plain'); + expect(dropdown.visible()).toBe(false); + }); +}); 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 5581e693..afda42b6 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 @@ -97,11 +97,16 @@ export class DropdownItemDirective implements FocusableOption { } onKeyUp($event: KeyboardEvent): void { - if ($event.key === 'Enter') { + if ($event.key === 'Enter' && !this.#clicksOnEnter()) { this.handleInteraction(); } } + #clicksOnEnter(): boolean { + const element: HTMLElement = this.#elementRef.nativeElement; + return element.tagName === 'BUTTON' || (element.tagName === 'A' && element.hasAttribute('href')); + } + private handleInteraction(): void { if (this.autoClose()) { this.#dropdownService.toggle({ visible: 'toggle', dropdown: this.dropdown }); From 3420dde2187f4d332be0ec33178c09d2645c81ab Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 14:03:30 +0200 Subject: [PATCH 04/17] fix(Dropdown): keyboard on toggle and menu matches other editions - ArrowDown/ArrowUp on toggle open menu, focus first/last item; skipped from inputs and disabled toggle - native Tab in menu, arrows continue from focused item - Escape from menu returns focus to toggle --- .../dropdown-menu.directive.spec.ts | 2 +- .../dropdown-menu/dropdown-menu.directive.ts | 35 ++++--- .../src/lib/dropdown/dropdown.service.ts | 1 + .../dropdown/dropdown.component.spec.ts | 96 ++++++++++++++++++- .../dropdown/dropdown/dropdown.component.ts | 23 ++++- 5 files changed, 142 insertions(+), 15 deletions(-) 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 e76da22f..f5fffc01 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 @@ -111,7 +111,7 @@ describe('DropdownMenuDirective', () => { component.visible.set(true); fixture.detectChanges(); elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' })); - elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab' })); + elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40 })); 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 afde8593..a39976fd 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,6 +1,7 @@ import { FocusKeyManager } from '@angular/cdk/a11y'; import { AfterContentInit, + afterNextRender, booleanAttribute, computed, contentChildren, @@ -9,6 +10,7 @@ import { ElementRef, forwardRef, inject, + Injector, input, linkedSignal, OnInit @@ -30,12 +32,13 @@ import { DropdownService } from '../dropdown.service'; '[class]': 'hostClasses()', '[style]': 'hostStyles()', '[attr.data-coreui-popper]': 'dataPopper()', - '(keydown)': 'onKeyDown($event)', - '(keyup)': 'onKeyUp($event)' + '(focusin)': 'onFocusIn($event)', + '(keydown)': 'onKeyDown($event)' } }) export class DropdownMenuDirective implements OnInit, AfterContentInit { readonly #destroyRef: DestroyRef = inject(DestroyRef); + readonly #injector = inject(Injector); public readonly elementRef: ElementRef = inject(ElementRef); readonly #dropdownService: DropdownService = inject(DropdownService); #focusKeyManager!: FocusKeyManager; @@ -88,16 +91,10 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { this.#focusKeyManager.onKeydown($event); } - onKeyUp($event: KeyboardEvent): void { - if (!this.visible()) { - return; - } - if (['Tab'].includes($event.key)) { - if (this.#focusKeyManager.activeItem) { - $event.shiftKey ? this.#focusKeyManager.setPreviousItemActive() : this.#focusKeyManager.setNextItemActive(); - } else { - this.#focusKeyManager.setFirstItemActive(); - } + onFocusIn($event: FocusEvent): void { + const index = this.itemElements().findIndex(({ nativeElement }) => nativeElement.contains($event.target)); + if (index > -1) { + this.#focusKeyManager.updateActiveItem(index); } } @@ -106,6 +103,11 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { { descendants: true } ); + private readonly itemElements = contentChildren( + forwardRef(() => DropdownItemDirective), + { descendants: true, read: ElementRef } + ); + readonly items$ = toObservable(this.dropdownItemsContent); ngAfterContentInit(): void { @@ -131,6 +133,15 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { this.#focusKeyManager?.setActiveItem(-1); } } + if (state.focus && this.visible()) { + afterNextRender( + () => + state.focus === 'first' + ? this.#focusKeyManager.setFirstItemActive() + : this.#focusKeyManager.setLastItemActive(), + { injector: this.#injector } + ); + } }), takeUntilDestroyed(this.#destroyRef) ) diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.service.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.service.ts index 3ac5035c..e607a783 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.service.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.service.ts @@ -6,6 +6,7 @@ import { DropdownAlignment } from '../coreui.types'; export interface IDropdownState { visible?: boolean | 'toggle'; dropdown?: any; + focus?: 'first' | 'last'; } @Injectable({ 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 5e9c0b60..230703d0 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 @@ -37,7 +37,13 @@ class MockElementRef extends ElementRef {} @Component({ template: ` - +
    { 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 }[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 focused by Tab', async () => { + await keydown(toggle, 'ArrowDown'); + items[1].focus(); + items[1].dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', bubbles: true })); + expect(document.activeElement).toBe(items[1]); + await keydown(items[1], 'ArrowDown'); + expect(document.activeElement).toBe(items[2]); + }); + + 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); + }); +}); 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 0aa4e497..a7e1a7f3 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -42,7 +42,8 @@ export abstract class DropdownToken {} host: { '[class]': 'hostClasses()', '[aria-expanded]': 'ariaExpanded', - '(click)': 'onClick($event)' + '(click)': 'onClick($event)', + '(keydown)': 'onKeyDown($event)' } }) export class DropdownToggleDirective implements AfterViewInit { @@ -100,6 +101,22 @@ export class DropdownToggleDirective implements AfterViewInit { !this.disabled() && this.#dropdownService.toggle({ visible: 'toggle', dropdown: this.dropdown }); } + onKeyDown($event: KeyboardEvent): void { + if ( + this.disabled() || + !['ArrowDown', 'ArrowUp'].includes($event.key) || + /^(input|textarea)$/i.test(($event.target as HTMLElement).tagName) + ) { + return; + } + $event.preventDefault(); + this.#dropdownService.toggle({ + visible: true, + dropdown: this.dropdown, + focus: $event.key === 'ArrowDown' ? 'first' : 'last' + }); + } + ngAfterViewInit(): void { const dropdownComponent = this.dropdownComponent(); if (dropdownComponent) { @@ -409,7 +426,11 @@ export class DropdownComponent implements OnDestroy, OnInit { this.#renderer.listen(this.#elementRef.nativeElement, 'keyup', (event) => { if (event.key === 'Escape' && this.autoClose() !== false) { event.stopPropagation(); + const focusInMenu = this._menuElementRef()?.nativeElement.contains(this.#document.activeElement); this.setVisibleState(false); + if (focusInMenu) { + this._toggler()?.elementRef.nativeElement.focus(); + } return; } }) From f2b94f2d2f40f9c01b074066f4640f61c97049e3 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 14:13:39 +0200 Subject: [PATCH 05/17] fix(Dropdown): toggle click focuses toggle Safari does not focus buttons on click; focus fell to body. Skipped when focus already inside toggle. --- .../dropdown/dropdown.component.spec.ts | 18 ++++++++++++++++++ .../dropdown/dropdown/dropdown.component.ts | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) 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 230703d0..00924749 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 @@ -319,6 +319,24 @@ describe('Dropdown keyboard', () => { expect(document.activeElement).toBe(items[2]); }); + 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(); 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 a7e1a7f3..6cc11124 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -98,7 +98,14 @@ 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 { From add3c0228f8d9074dd602ce8a41458a8d23a2d6b Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 14:46:01 +0200 Subject: [PATCH 06/17] fix(Dropdown): anchor toggle is a focusable button gets role=button, tabindex=0 without href; Enter/Space toggle it. Explicit role/tabindex kept. --- .../dropdown/dropdown.component.spec.ts | 74 ++++++++++++++++++- .../dropdown/dropdown/dropdown.component.ts | 23 ++++++ 2 files changed, 94 insertions(+), 3 deletions(-) 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 00924749..fdab7bf4 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,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DropdownComponent, DropdownToggleDirective } from './dropdown.component'; -import { Component, DebugElement, DOCUMENT, ElementRef, input, signal } from '@angular/core'; +import { Component, DebugElement, DOCUMENT, ElementRef, input, Renderer2, signal } from '@angular/core'; import { DropdownAlignment } from '../../coreui.types'; import { DropdownService } from '../dropdown.service'; import { By } from '@angular/platform-browser'; @@ -84,8 +84,8 @@ describe('DropdownToggleDirective', () => { providers: [ { provide: ElementRef, useClass: MockElementRef }, DropdownService, - DropdownComponent - // Renderer2, + DropdownComponent, + Renderer2 // ChangeDetectorRef ] }); @@ -349,3 +349,71 @@ describe('Dropdown keyboard', () => { expect(document.activeElement).toBe(toggle); }); }); + +@Component({ + template: ` + + Toggle +
      +
    • +
    + + + Link +
      +
      + + Custom +
        +
        + + +
          +
          + `, + imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective] +}) +class AnchorToggleComponent { + readonly visible = 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 () => { + fixture = TestBed.createComponent(AnchorToggleComponent); + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('should expose an anchor toggle as a focusable button', () => { + expect(element('toggle').getAttribute('role')).toBe('button'); + expect(element('toggle').getAttribute('tabindex')).toBe('0'); + 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); + }); + + 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 leave Enter on an anchor with href to the native click', async () => { + expect(await keydown(element('link'), 'Enter')).toBe(false); + }); +}); 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 6cc11124..a848b35f 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -48,9 +48,22 @@ export abstract class DropdownToken {} }) export class DropdownToggleDirective implements AfterViewInit { public readonly elementRef = inject(ElementRef); + readonly #renderer = inject(Renderer2); #dropdownService = inject(DropdownService); public dropdown = inject(DropdownToken, { optional: true }); + constructor() { + const element: HTMLElement | undefined = this.elementRef.nativeElement; + if (element?.tagName === 'A') { + if (!element.hasAttribute('role')) { + this.#renderer.setAttribute(element, 'role', 'button'); + } + if (!element.hasAttribute('href') && !element.hasAttribute('tabindex')) { + this.#renderer.setAttribute(element, 'tabindex', '0'); + } + } + } + /** * Reference to dropdown component. * @returns DropdownComponent @@ -109,6 +122,16 @@ export class DropdownToggleDirective implements AfterViewInit { } onKeyDown($event: KeyboardEvent): void { + const element: HTMLElement = this.elementRef.nativeElement; + if ( + !this.disabled() && + element.tagName === 'A' && + ($event.key === ' ' || ($event.key === 'Enter' && !element.hasAttribute('href'))) + ) { + $event.preventDefault(); + element.click(); + return; + } if ( this.disabled() || !['ArrowDown', 'ArrowUp'].includes($event.key) || From 57cfd0fc9d498684fad42a2373085aa79d416f7a Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 15:00:23 +0200 Subject: [PATCH 07/17] fix(Dropdown): item has no default role role="list-item" is not a valid ARIA role and hid link/button semantics; role input still overrides. --- .../dropdown/dropdown-item/dropdown-item.directive.spec.ts | 1 + .../lib/dropdown/dropdown-item/dropdown-item.directive.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) 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 564c81f1..8126b718 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 @@ -58,6 +58,7 @@ describe('DropdownItemDirective', () => { }); it('should have css classes and attributes', async () => { + expect(elementRef.nativeElement.hasAttribute('role')).toBe(false); expect(elementRef.nativeElement.classList.contains('disabled')).toBe(false); expect(elementRef.nativeElement.getAttribute('aria-disabled')).toBeNull(); expect(elementRef.nativeElement.getAttribute('aria-current')).toBeNull(); 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 afda42b6..e8a9add8 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 @@ -54,11 +54,11 @@ export class DropdownItemDirective implements FocusableOption { } /** - * Default role for dropdown-item. + * Role for dropdown-item. * @returns string - * @default 'list-item' + * @default undefined */ - readonly role = input('list-item'); + readonly role = input(); /** * Tab index of the dropdown-item. From fe5d17689683b53f54435c2f3d7a09587f371b09 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 16:34:54 +0200 Subject: [PATCH 08/17] fix(Dropdown): read anchor toggle href after first render RouterLink binds href during change detection, after the directive constructor; tabindex fallback moved to afterNextRender so a routerLink toggle gets no redundant tabindex. --- .../dropdown/dropdown.component.spec.ts | 11 +++++++++- .../dropdown/dropdown/dropdown.component.ts | 20 ++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) 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 fdab7bf4..ed6b2330 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 @@ -5,6 +5,7 @@ import { Component, DebugElement, DOCUMENT, ElementRef, input, Renderer2, signal import { DropdownAlignment } from '../../coreui.types'; import { DropdownService } from '../dropdown.service'; import { By } from '@angular/platform-browser'; +import { provideRouter, RouterLink } from '@angular/router'; import { DropdownMenuDirective } from '../dropdown-menu/dropdown-menu.directive'; import { DropdownItemDirective } from '../dropdown-item/dropdown-item.directive'; @@ -370,8 +371,12 @@ describe('Dropdown keyboard', () => {
            + + Router +
              +
              `, - imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective] + imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective, RouterLink] }) class AnchorToggleComponent { readonly visible = signal(false); @@ -389,6 +394,7 @@ describe('DropdownToggleDirective on anchor', () => { }; beforeEach(async () => { + TestBed.configureTestingModule({ providers: [provideRouter([])] }); fixture = TestBed.createComponent(AnchorToggleComponent); fixture.detectChanges(); await fixture.whenStable(); @@ -403,6 +409,9 @@ describe('DropdownToggleDirective on anchor', () => { 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 () => { 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 a848b35f..a09b3821 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -1,4 +1,5 @@ import { + afterNextRender, AfterViewInit, booleanAttribute, ChangeDetectorRef, @@ -54,14 +55,19 @@ export class DropdownToggleDirective implements AfterViewInit { constructor() { const element: HTMLElement | undefined = this.elementRef.nativeElement; - if (element?.tagName === 'A') { - if (!element.hasAttribute('role')) { - this.#renderer.setAttribute(element, 'role', 'button'); - } - if (!element.hasAttribute('href') && !element.hasAttribute('tabindex')) { - this.#renderer.setAttribute(element, 'tabindex', '0'); - } + if (element?.tagName !== 'A') { + return; + } + if (!element.hasAttribute('role')) { + this.#renderer.setAttribute(element, 'role', 'button'); } + afterNextRender({ + write: () => { + if (!element.hasAttribute('href') && !element.hasAttribute('tabindex')) { + this.#renderer.setAttribute(element, 'tabindex', '0'); + } + } + }); } /** From 3754b0d68c6c8e4ac046f05e6a1fe2422a741935 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 17:57:50 +0200 Subject: [PATCH 09/17] fix(Dropdown): keys in form controls, native-click items, external toggle - menu and toggle ignore every key from input, select, textarea and contenteditable - shared clicksOnEnter/clicksOnSpace: input buttons and summary count as native click - anchor toggle without href is not made focusable; needs an href like other anchors - toggle wired with [dropdownComponent] gets listeners and focus back on Escape - one content query for menu items; spec Renderer2 stub --- .../dropdown-item.directive.spec.ts | 3 + .../dropdown-item/dropdown-item.directive.ts | 14 ++--- .../dropdown-menu.directive.spec.ts | 21 +++++++ .../dropdown-menu/dropdown-menu.directive.ts | 19 +++---- .../src/lib/dropdown/dropdown.utils.spec.ts | 41 ++++++++++++++ .../src/lib/dropdown/dropdown.utils.ts | 24 ++++++++ .../dropdown/dropdown.component.spec.ts | 56 ++++++++++++++++++- .../dropdown/dropdown/dropdown.component.ts | 30 ++++------ 8 files changed, 165 insertions(+), 43 deletions(-) create mode 100644 projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts create mode 100644 projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts 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 8126b718..2826d59d 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 @@ -93,6 +93,7 @@ describe('DropdownItemDirective', () => {
              • Link
              • +
              • Plain
              @@ -117,6 +118,8 @@ describe('DropdownItemDirective Enter', () => { expect(dropdown.visible()).toBe(true); keyup('link'); expect(dropdown.visible()).toBe(true); + keyup('inputButton'); + expect(dropdown.visible()).toBe(true); keyup('plain'); expect(dropdown.visible()).toBe(false); }); 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 e8a9add8..68ee4809 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,6 +1,7 @@ import { FocusableOption, FocusOrigin } from '@angular/cdk/a11y'; import { booleanAttribute, computed, Directive, ElementRef, inject, input, linkedSignal } from '@angular/core'; import { DropdownService } from '../dropdown.service'; +import { clicksOnEnter } from '../dropdown.utils'; import { DropdownComponent } from '../dropdown/dropdown.component'; @Directive({ @@ -18,7 +19,7 @@ import { DropdownComponent } from '../dropdown/dropdown.component'; } }) export class DropdownItemDirective implements FocusableOption { - readonly #elementRef: ElementRef = inject(ElementRef); + public readonly elementRef: ElementRef = inject(ElementRef); readonly #dropdownService = inject(DropdownService); dropdown? = inject(DropdownComponent, { optional: true }); @@ -73,11 +74,11 @@ export class DropdownItemDirective implements FocusableOption { }); focus(origin?: FocusOrigin | undefined): void { - this.#elementRef?.nativeElement?.focus(); + this.elementRef?.nativeElement?.focus(); } getLabel?(): string { - return this.#elementRef?.nativeElement?.textContent.trim(); + return this.elementRef?.nativeElement?.textContent.trim(); } readonly ariaCurrent = computed(() => { @@ -97,16 +98,11 @@ export class DropdownItemDirective implements FocusableOption { } onKeyUp($event: KeyboardEvent): void { - if ($event.key === 'Enter' && !this.#clicksOnEnter()) { + if ($event.key === 'Enter' && !clicksOnEnter(this.elementRef.nativeElement)) { this.handleInteraction(); } } - #clicksOnEnter(): boolean { - const element: HTMLElement = this.#elementRef.nativeElement; - return element.tagName === 'BUTTON' || (element.tagName === 'A' && element.hasAttribute('href')); - } - 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 f5fffc01..68d51b1e 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 @@ -126,6 +126,7 @@ describe('DropdownMenuDirective', () => {
            • Link
            • +
            • `, @@ -146,8 +147,28 @@ describe('DropdownMenuDirective Space', () => { expect(press(menu.querySelector('#button'), 'Space')).toBe(false); expect(press(menu.querySelector('#field'), 'Space')).toBe(false); + expect(press(menu.querySelector('#check'), 'Space')).toBe(false); expect(press(menu.querySelector('#link'), 'Space')).toBe(true); expect(press(menu, 'Space')).toBe(true); expect(press(menu.querySelector('#button'), 'ArrowDown')).toBe(true); }); + + it('should leave every key to a form control inside the menu', () => { + const fixture = TestBed.createComponent(SpaceTestComponent); + fixture.detectChanges(); + const menu = fixture.debugElement.query(By.directive(DropdownMenuDirective)).nativeElement; + const field: HTMLInputElement = menu.querySelector('#field'); + field.focus(); + for (const [key, keyCode] of [ + ['Home', 36], + ['End', 35], + ['ArrowDown', 40], + ['ArrowUp', 38] + ] as const) { + const event = new KeyboardEvent('keydown', { key, keyCode, bubbles: true, cancelable: true }); + field.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(document.activeElement).toBe(field); + } + }); }); 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 a39976fd..c7186aee 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 @@ -22,6 +22,7 @@ import { ThemeDirective } from '../../shared/theme.directive'; import { DropdownItemDirective } from '../dropdown-item/dropdown-item.directive'; import { BreakpointInfix, DropdownAlignment } from '../../coreui.types'; import { DropdownService } from '../dropdown.service'; +import { clicksOnSpace, isEditableTarget } from '../dropdown.utils'; @Directive({ selector: '[cDropdownMenu]', @@ -82,17 +83,19 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { readonly dataPopper = computed(() => (this.#dropdownService.popper() ? null : 'static')); onKeyDown($event: KeyboardEvent): void { - if (!this.visible()) { + if (!this.visible() || isEditableTarget($event.target)) { return; } - if ($event.code === 'ArrowDown' || ($event.code === 'Space' && !this.#handlesSpace($event.target))) { + if ($event.code === 'ArrowDown' || ($event.code === 'Space' && !this.#clicksOnSpace($event.target))) { $event.preventDefault(); } this.#focusKeyManager.onKeydown($event); } onFocusIn($event: FocusEvent): void { - const index = this.itemElements().findIndex(({ nativeElement }) => nativeElement.contains($event.target)); + const index = this.dropdownItemsContent().findIndex((item) => + item.elementRef.nativeElement.contains($event.target) + ); if (index > -1) { this.#focusKeyManager.updateActiveItem(index); } @@ -103,11 +106,6 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { { descendants: true } ); - private readonly itemElements = contentChildren( - forwardRef(() => DropdownItemDirective), - { descendants: true, read: ElementRef } - ); - readonly items$ = toObservable(this.dropdownItemsContent); ngAfterContentInit(): void { @@ -148,9 +146,8 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { .subscribe(); } - #handlesSpace(target: EventTarget | null): boolean { - const control = (target as Element | null)?.closest?.('button, input, select, textarea, [contenteditable]'); - return !!control && this.elementRef.nativeElement.contains(control); + #clicksOnSpace(target: EventTarget | null): boolean { + return target instanceof Element && clicksOnSpace(target); } private focusKeyManagerInit(): void { diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts new file mode 100644 index 00000000..c5391315 --- /dev/null +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts @@ -0,0 +1,41 @@ +/// +import { clicksOnEnter, clicksOnSpace, isEditableTarget } from './dropdown.utils'; + +const element = (html: string): Element => { + const template = document.createElement('template'); + template.innerHTML = html; + return template.content.firstElementChild as Element; +}; + +describe('dropdown.utils', () => { + it('isEditableTarget should match form controls and editable hosts, also from a descendant', () => { + expect(isEditableTarget(element(''))).toBe(true); + expect(isEditableTarget(element(''))).toBe(true); + expect(isEditableTarget(element(''))).toBe(true); + expect(isEditableTarget(element('
              x
              ').querySelector('#x'))).toBe(true); + expect(isEditableTarget(element(''))).toBe(false); + expect(isEditableTarget(element('a'))).toBe(false); + expect(isEditableTarget(null)).toBe(false); + }); + + it('clicksOnEnter should be true only for elements the browser clicks on Enter', () => { + expect(clicksOnEnter(element(''))).toBe(true); + expect(clicksOnEnter(element(''))).toBe(true); + expect(clicksOnEnter(element(''))).toBe(true); + expect(clicksOnEnter(element('a'))).toBe(true); + expect(clicksOnEnter(element('s'))).toBe(true); + expect(clicksOnEnter(element('a'))).toBe(false); + expect(clicksOnEnter(element(''))).toBe(false); + expect(clicksOnEnter(element('
              d
              '))).toBe(false); + }); + + it('clicksOnSpace should be true only for elements the browser clicks on Space', () => { + expect(clicksOnSpace(element(''))).toBe(true); + expect(clicksOnSpace(element(''))).toBe(true); + expect(clicksOnSpace(element(''))).toBe(true); + expect(clicksOnSpace(element(''))).toBe(true); + expect(clicksOnSpace(element('s'))).toBe(true); + expect(clicksOnSpace(element('a'))).toBe(false); + expect(clicksOnSpace(element(''))).toBe(false); + }); +}); diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts new file mode 100644 index 00000000..ac0bbf53 --- /dev/null +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts @@ -0,0 +1,24 @@ +const EDITABLE_SELECTOR = 'input, select, textarea, [contenteditable]'; + +/** + * Whether the event target types or selects natively, so dropdown key handling must leave it alone. + */ +export const isEditableTarget = (target: EventTarget | null): boolean => + target instanceof Element && target.closest(EDITABLE_SELECTOR) !== null; + +/** + * Whether the element dispatches a native click on Enter. + */ +export const clicksOnEnter = (element: Element): boolean => + element instanceof HTMLButtonElement || + (element instanceof HTMLInputElement && /^(button|image|reset|submit)$/.test(element.type)) || + (element instanceof HTMLAnchorElement && element.hasAttribute('href')) || + element.tagName === 'SUMMARY'; + +/** + * Whether the element dispatches a native click on Space. + */ +export const clicksOnSpace = (element: Element): boolean => + element instanceof HTMLButtonElement || + (element instanceof HTMLInputElement && /^(button|checkbox|image|radio|reset|submit)$/.test(element.type)) || + element.tagName === 'SUMMARY'; 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 ed6b2330..b41ad37b 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 @@ -86,7 +86,7 @@ describe('DropdownToggleDirective', () => { { provide: ElementRef, useClass: MockElementRef }, DropdownService, DropdownComponent, - Renderer2 + { provide: Renderer2, useValue: { setAttribute: vi.fn() } } // ChangeDetectorRef ] }); @@ -375,11 +375,16 @@ describe('Dropdown keyboard', () => { Router
                + + Search +
                  +
                  `, imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective, RouterLink] }) class AnchorToggleComponent { readonly visible = signal(false); + readonly nestedVisible = signal(false); } describe('DropdownToggleDirective on anchor', () => { @@ -400,9 +405,9 @@ describe('DropdownToggleDirective on anchor', () => { await fixture.whenStable(); }); - it('should expose an anchor toggle as a focusable button', () => { + 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').getAttribute('tabindex')).toBe('0'); + 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'); @@ -422,7 +427,52 @@ describe('DropdownToggleDirective on anchor', () => { expect(fixture.componentInstance.visible()).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 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); + }); +}); 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 a09b3821..86058360 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -1,5 +1,4 @@ import { - afterNextRender, AfterViewInit, booleanAttribute, ChangeDetectorRef, @@ -32,6 +31,7 @@ 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 } from '../dropdown.utils'; // lightweight injection token export abstract class DropdownToken {} @@ -61,13 +61,6 @@ export class DropdownToggleDirective implements AfterViewInit { if (!element.hasAttribute('role')) { this.#renderer.setAttribute(element, 'role', 'button'); } - afterNextRender({ - write: () => { - if (!element.hasAttribute('href') && !element.hasAttribute('tabindex')) { - this.#renderer.setAttribute(element, 'tabindex', '0'); - } - } - }); } /** @@ -128,21 +121,16 @@ export class DropdownToggleDirective implements AfterViewInit { } onKeyDown($event: KeyboardEvent): void { + if (this.disabled() || isEditableTarget($event.target)) { + return; + } const element: HTMLElement = this.elementRef.nativeElement; - if ( - !this.disabled() && - element.tagName === 'A' && - ($event.key === ' ' || ($event.key === 'Enter' && !element.hasAttribute('href'))) - ) { + if (element.tagName === 'A' && ($event.key === ' ' || ($event.key === 'Enter' && !clicksOnEnter(element)))) { $event.preventDefault(); element.click(); return; } - if ( - this.disabled() || - !['ArrowDown', 'ArrowUp'].includes($event.key) || - /^(input|textarea)$/i.test(($event.target as HTMLElement).tagName) - ) { + if (!['ArrowDown', 'ArrowUp'].includes($event.key)) { return; } $event.preventDefault(); @@ -158,6 +146,7 @@ export class DropdownToggleDirective implements AfterViewInit { if (dropdownComponent) { this.dropdown = dropdownComponent; this.#dropdownService = dropdownComponent?.dropdownService; + dropdownComponent.toggler = this; } } } @@ -326,6 +315,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 }); @@ -405,7 +395,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(() => { @@ -465,7 +455,7 @@ export class DropdownComponent implements OnDestroy, OnInit { const focusInMenu = this._menuElementRef()?.nativeElement.contains(this.#document.activeElement); this.setVisibleState(false); if (focusInMenu) { - this._toggler()?.elementRef.nativeElement.focus(); + (this._toggler() ?? this.toggler)?.elementRef.nativeElement.focus(); } return; } From 4dc19af2f11c742a5b3db73cb526a2d002e4313d Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 18:11:41 +0200 Subject: [PATCH 10/17] fix(Dropdown): adversary follow-ups - input buttons are not editable: arrows and Space work on an input button item - anchor toggle ignores repeated Space/Enter; role=button only without interactive content - external toggle: document click exempts it, Escape on it closes, Tab from it does not - Escape focus return falls back to the first interactive element in a non-focusable toggle host - item keyup judges the event target, not the host --- .../dropdown-item.directive.spec.ts | 7 +- .../dropdown-item/dropdown-item.directive.ts | 2 +- .../dropdown-menu.directive.spec.ts | 15 +++ .../src/lib/dropdown/dropdown.utils.spec.ts | 8 ++ .../src/lib/dropdown/dropdown.utils.ts | 3 +- .../dropdown/dropdown.component.spec.ts | 114 ++++++++++++++++-- .../dropdown/dropdown/dropdown.component.ts | 56 +++++---- 7 files changed, 170 insertions(+), 35 deletions(-) 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 2826d59d..c1dfbe21 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 @@ -94,6 +94,7 @@ describe('DropdownItemDirective', () => {
                • Link
                • +
                • Plain
                • @@ -110,7 +111,9 @@ describe('DropdownItemDirective Enter', () => { fixture.detectChanges(); const dropdown = fixture.componentInstance.dropdown(); const keyup = (id: string) => { - fixture.nativeElement.querySelector(`#${id}`).dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + fixture.nativeElement + .querySelector(`#${id}`) + .dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true })); fixture.detectChanges(); }; @@ -120,6 +123,8 @@ describe('DropdownItemDirective Enter', () => { expect(dropdown.visible()).toBe(true); keyup('inputButton'); expect(dropdown.visible()).toBe(true); + keyup('inner'); + expect(dropdown.visible()).toBe(true); keyup('plain'); expect(dropdown.visible()).toBe(false); }); 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 68ee4809..62f9ad98 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 @@ -98,7 +98,7 @@ export class DropdownItemDirective implements FocusableOption { } onKeyUp($event: KeyboardEvent): void { - if ($event.key === 'Enter' && !clicksOnEnter(this.elementRef.nativeElement)) { + if ($event.key === 'Enter' && !clicksOnEnter($event.target as Element)) { this.handleInteraction(); } } 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 68d51b1e..2e3d2712 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 @@ -112,6 +112,7 @@ describe('DropdownMenuDirective', () => { fixture.detectChanges(); elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' })); elementRef.nativeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40 })); + expect(document.activeElement).toEqual(itemRef.nativeElement); elementRef.nativeElement.focus(); fixture.detectChanges(); expect(document.activeElement).toEqual(itemRef.nativeElement); @@ -124,6 +125,7 @@ describe('DropdownMenuDirective', () => {
                  • +
                  • Link
                  • @@ -146,6 +148,7 @@ describe('DropdownMenuDirective Space', () => { }; expect(press(menu.querySelector('#button'), 'Space')).toBe(false); + expect(press(menu.querySelector('#inputItem'), 'Space')).toBe(false); expect(press(menu.querySelector('#field'), 'Space')).toBe(false); expect(press(menu.querySelector('#check'), 'Space')).toBe(false); expect(press(menu.querySelector('#link'), 'Space')).toBe(true); @@ -153,6 +156,18 @@ describe('DropdownMenuDirective Space', () => { expect(press(menu.querySelector('#button'), 'ArrowDown')).toBe(true); }); + it('should move the arrows from an input button item', () => { + const fixture = TestBed.createComponent(SpaceTestComponent); + fixture.detectChanges(); + const menu = fixture.debugElement.query(By.directive(DropdownMenuDirective)).nativeElement; + const inputItem: HTMLInputElement = menu.querySelector('#inputItem'); + inputItem.focus(); + const event = new KeyboardEvent('keydown', { key: 'ArrowDown', keyCode: 40, bubbles: true, cancelable: true }); + inputItem.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(menu.querySelector('#link')); + }); + it('should leave every key to a form control inside the menu', () => { const fixture = TestBed.createComponent(SpaceTestComponent); fixture.detectChanges(); diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts index c5391315..f20c3205 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts @@ -13,6 +13,9 @@ describe('dropdown.utils', () => { expect(isEditableTarget(element(''))).toBe(true); expect(isEditableTarget(element(''))).toBe(true); expect(isEditableTarget(element('
                    x
                    ').querySelector('#x'))).toBe(true); + expect(isEditableTarget(element(''))).toBe(true); + expect(isEditableTarget(element(''))).toBe(false); + expect(isEditableTarget(element(''))).toBe(false); expect(isEditableTarget(element(''))).toBe(false); expect(isEditableTarget(element('a'))).toBe(false); expect(isEditableTarget(null)).toBe(false); @@ -23,6 +26,8 @@ describe('dropdown.utils', () => { expect(clicksOnEnter(element(''))).toBe(true); expect(clicksOnEnter(element(''))).toBe(true); expect(clicksOnEnter(element('a'))).toBe(true); + expect(clicksOnEnter(element(''))).toBe(true); + expect(clicksOnEnter(element(''))).toBe(true); expect(clicksOnEnter(element('s'))).toBe(true); expect(clicksOnEnter(element('a'))).toBe(false); expect(clicksOnEnter(element(''))).toBe(false); @@ -34,6 +39,9 @@ describe('dropdown.utils', () => { expect(clicksOnSpace(element(''))).toBe(true); expect(clicksOnSpace(element(''))).toBe(true); expect(clicksOnSpace(element(''))).toBe(true); + expect(clicksOnSpace(element(''))).toBe(true); + expect(clicksOnSpace(element(''))).toBe(true); + expect(clicksOnSpace(element(''))).toBe(true); expect(clicksOnSpace(element('s'))).toBe(true); expect(clicksOnSpace(element('a'))).toBe(false); expect(clicksOnSpace(element(''))).toBe(false); diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts index ac0bbf53..fd92be8f 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts @@ -1,4 +1,5 @@ -const EDITABLE_SELECTOR = 'input, select, textarea, [contenteditable]'; +const EDITABLE_SELECTOR = + 'input:not([type="button"]):not([type="image"]):not([type="reset"]):not([type="submit"]), select, textarea, [contenteditable]'; /** * Whether the event target types or selects natively, so dropdown key handling must leave it alone. 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 b41ad37b..4a244721 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 @@ -171,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); @@ -248,7 +248,7 @@ describe('DropdownComponent alignment scope', () => { @Component({ template: ` - +
                    • @@ -259,6 +259,7 @@ describe('DropdownComponent alignment scope', () => { imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective] }) class KeyboardTestComponent { + readonly disabled = signal(false); readonly visible = signal(false); } @@ -268,7 +269,7 @@ describe('Dropdown keyboard', () => { let items: HTMLElement[]; const keydown = async (target: HTMLElement, key: string) => { - const keyCode = { ArrowDown: 40, ArrowUp: 38 }[key]; + 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(); @@ -311,13 +312,39 @@ describe('Dropdown keyboard', () => { expect(document.activeElement).toBe(field); }); - it('should continue arrow navigation from the item focused by Tab', async () => { + it('should continue arrow navigation from the item that received focus', async () => { await keydown(toggle, 'ArrowDown'); - items[1].focus(); - items[1].dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', bubbles: true })); + items[2].focus(); + await keydown(items[2], 'ArrowUp'); expect(document.activeElement).toBe(items[1]); - await keydown(items[1], 'ArrowDown'); + }); + + 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 ignore the arrows on a disabled toggle', async () => { + fixture.componentInstance.disabled.set(true); + fixture.detectChanges(); + await keydown(toggle, 'ArrowDown'); + expect(fixture.componentInstance.visible()).toBe(false); + }); + + 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 () => { @@ -427,6 +454,19 @@ describe('DropdownToggleDirective on anchor', () => { 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 not force a role on an anchor toggle with interactive content', () => { + expect(element('nested').hasAttribute('role')).toBe(false); + }); + it('should leave Space and Enter to a field inside an anchor toggle', async () => { const field = element('nestedField'); field.focus(); @@ -456,6 +496,35 @@ class ExternalToggleComponent { } 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(); @@ -476,3 +545,34 @@ describe('DropdownToggleDirective outside the dropdown', () => { 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 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')); + }); +}); 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 86058360..fc8f5499 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -33,6 +33,8 @@ import { DropdownMenuDirective } from '../dropdown-menu/dropdown-menu.directive' import { DropdownService } from '../dropdown.service'; import { clicksOnEnter, isEditableTarget } from '../dropdown.utils'; +const INTERACTIVE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]'; + // lightweight injection token export abstract class DropdownToken {} @@ -53,16 +55,6 @@ export class DropdownToggleDirective implements AfterViewInit { #dropdownService = inject(DropdownService); public dropdown = inject(DropdownToken, { optional: true }); - constructor() { - const element: HTMLElement | undefined = this.elementRef.nativeElement; - if (element?.tagName !== 'A') { - return; - } - if (!element.hasAttribute('role')) { - this.#renderer.setAttribute(element, 'role', 'button'); - } - } - /** * Reference to dropdown component. * @returns DropdownComponent @@ -127,7 +119,9 @@ export class DropdownToggleDirective implements AfterViewInit { const element: HTMLElement = this.elementRef.nativeElement; if (element.tagName === 'A' && ($event.key === ' ' || ($event.key === 'Enter' && !clicksOnEnter(element)))) { $event.preventDefault(); - element.click(); + if (!$event.repeat) { + element.click(); + } return; } if (!['ArrowDown', 'ArrowUp'].includes($event.key)) { @@ -142,6 +136,10 @@ export class DropdownToggleDirective implements AfterViewInit { } ngAfterViewInit(): void { + const element: HTMLElement = this.elementRef.nativeElement; + if (element.tagName === 'A' && !element.hasAttribute('role') && !element.querySelector(INTERACTIVE_SELECTOR)) { + this.#renderer.setAttribute(element, 'role', 'button'); + } const dropdownComponent = this.dropdownComponent(); if (dropdownComponent) { this.dropdown = dropdownComponent; @@ -430,7 +428,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(); @@ -449,32 +447,40 @@ export class DropdownComponent implements OnDestroy, OnInit { }) ); this.listeners.push( - this.#renderer.listen(this.#elementRef.nativeElement, 'keyup', (event) => { - if (event.key === 'Escape' && this.autoClose() !== false) { + this.#renderer.listen(this.#document, 'keyup', (event) => { + if (this.autoClose() === false) { + return; + } + const inside = + this.#elementRef.nativeElement.contains(event.target) || this.#togglerElement()?.contains(event.target); + if (event.key === 'Escape' && inside) { event.stopPropagation(); const focusInMenu = this._menuElementRef()?.nativeElement.contains(this.#document.activeElement); this.setVisibleState(false); if (focusInMenu) { - (this._toggler() ?? this.toggler)?.elementRef.nativeElement.focus(); + this.#focusToggler(); } return; } - }) - ); - 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' && !inside) { this.setVisibleState(false); - return; } }) ); } + #togglerElement(): HTMLElement | undefined { + return (this._toggler() ?? this.toggler)?.elementRef.nativeElement; + } + + #focusToggler(): void { + const toggler = this.#togglerElement(); + toggler?.focus(); + if (toggler && this.#document.activeElement !== toggler) { + toggler.querySelector(INTERACTIVE_SELECTOR)?.focus(); + } + } + private clearListeners(): void { this.listeners.forEach((unListen) => { unListen(); From 9411ebd773bfd0fd9265a453ed00e9d3e37c1fdd Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 18:29:15 +0200 Subject: [PATCH 11/17] fix(Dropdown): second adversary round - Escape listener back on the dropdown host (nested dropdowns, ancestors, shadow DOM); an external toggle gets its own - Tab close check reads composedPath - anchor toggle handles Enter/Space only when the key targets the anchor; role=button on every anchor toggle - Escape focus return skips hidden, disabled, aria-hidden and tabindex=-1 candidates - item keyup leaves Enter to an editable target --- .../dropdown-item.directive.spec.ts | 3 + .../dropdown-item/dropdown-item.directive.ts | 5 +- .../dropdown/dropdown.component.spec.ts | 88 ++++++++++++++++--- .../dropdown/dropdown/dropdown.component.ts | 74 +++++++++++----- 4 files changed, 131 insertions(+), 39 deletions(-) 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 c1dfbe21..a0441bc7 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 @@ -95,6 +95,7 @@ describe('DropdownItemDirective', () => {
                    • Link
                    • +
                    • Plain
                    @@ -125,6 +126,8 @@ describe('DropdownItemDirective Enter', () => { expect(dropdown.visible()).toBe(true); keyup('inner'); expect(dropdown.visible()).toBe(true); + keyup('query'); + expect(dropdown.visible()).toBe(true); keyup('plain'); expect(dropdown.visible()).toBe(false); }); 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 62f9ad98..228f59bb 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,7 +1,7 @@ import { FocusableOption, FocusOrigin } from '@angular/cdk/a11y'; import { booleanAttribute, computed, Directive, ElementRef, inject, input, linkedSignal } from '@angular/core'; import { DropdownService } from '../dropdown.service'; -import { clicksOnEnter } from '../dropdown.utils'; +import { clicksOnEnter, isEditableTarget } from '../dropdown.utils'; import { DropdownComponent } from '../dropdown/dropdown.component'; @Directive({ @@ -98,7 +98,8 @@ export class DropdownItemDirective implements FocusableOption { } onKeyUp($event: KeyboardEvent): void { - if ($event.key === 'Enter' && !clicksOnEnter($event.target as Element)) { + const target = $event.target as Element; + if ($event.key === 'Enter' && !isEditableTarget(target) && !clicksOnEnter(target)) { this.handleInteraction(); } } 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 4a244721..adb644f0 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 @@ -248,7 +248,7 @@ describe('DropdownComponent alignment scope', () => { @Component({ template: ` - +
                    • @@ -259,7 +259,6 @@ describe('DropdownComponent alignment scope', () => { imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective] }) class KeyboardTestComponent { - readonly disabled = signal(false); readonly visible = signal(false); } @@ -330,13 +329,6 @@ describe('Dropdown keyboard', () => { expect(document.activeElement).toBe(items[0]); }); - it('should ignore the arrows on a disabled toggle', async () => { - fixture.componentInstance.disabled.set(true); - fixture.detectChanges(); - await keydown(toggle, 'ArrowDown'); - expect(fixture.componentInstance.visible()).toBe(false); - }); - 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); @@ -403,13 +395,18 @@ describe('Dropdown keyboard', () => {
                        - Search + Search +
                          +
                          + + Off
                            `, imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective, RouterLink] }) class AnchorToggleComponent { + readonly disabledVisible = signal(false); readonly visible = signal(false); readonly nestedVisible = signal(false); } @@ -463,8 +460,21 @@ describe('DropdownToggleDirective on anchor', () => { expect(fixture.componentInstance.visible()).toBe(true); }); - it('should not force a role on an anchor toggle with interactive content', () => { - expect(element('nested').hasAttribute('role')).toBe(false); + it('should expose an anchor toggle with interactive content as a button too', () => { + expect(element('nested').getAttribute('role')).toBe('button'); + }); + + it('should leave Enter and Space to 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); + expect(fixture.componentInstance.nestedVisible()).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 () => { @@ -549,7 +559,13 @@ describe('DropdownToggleDirective outside the dropdown', () => { @Component({ template: ` -
                            +
                            + + + + + +
                            @@ -576,3 +592,49 @@ describe('DropdownToggleDirective on a non-focusable host', () => { 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')); + }); +}); 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 fc8f5499..9b5ec855 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -33,7 +33,8 @@ import { DropdownMenuDirective } from '../dropdown-menu/dropdown-menu.directive' import { DropdownService } from '../dropdown.service'; import { clicksOnEnter, isEditableTarget } from '../dropdown.utils'; -const INTERACTIVE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]'; +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; // lightweight injection token export abstract class DropdownToken {} @@ -117,7 +118,11 @@ export class DropdownToggleDirective implements AfterViewInit { return; } const element: HTMLElement = this.elementRef.nativeElement; - if (element.tagName === 'A' && ($event.key === ' ' || ($event.key === 'Enter' && !clicksOnEnter(element)))) { + if ( + $event.target === element && + element.tagName === 'A' && + ($event.key === ' ' || ($event.key === 'Enter' && !clicksOnEnter(element))) + ) { $event.preventDefault(); if (!$event.repeat) { element.click(); @@ -135,11 +140,14 @@ export class DropdownToggleDirective implements AfterViewInit { }); } - ngAfterViewInit(): void { - const element: HTMLElement = this.elementRef.nativeElement; - if (element.tagName === 'A' && !element.hasAttribute('role') && !element.querySelector(INTERACTIVE_SELECTOR)) { + constructor() { + const element: HTMLElement | undefined = this.elementRef.nativeElement; + if (element?.tagName === 'A' && !element.hasAttribute('role')) { this.#renderer.setAttribute(element, 'role', 'button'); } + } + + ngAfterViewInit(): void { const dropdownComponent = this.dropdownComponent(); if (dropdownComponent) { this.dropdown = dropdownComponent; @@ -446,38 +454,56 @@ 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.#document, 'keyup', (event) => { - if (this.autoClose() === false) { - return; - } - const inside = - this.#elementRef.nativeElement.contains(event.target) || this.#togglerElement()?.contains(event.target); - if (event.key === 'Escape' && inside) { - event.stopPropagation(); - const focusInMenu = this._menuElementRef()?.nativeElement.contains(this.#document.activeElement); - this.setVisibleState(false); - if (focusInMenu) { - this.#focusToggler(); - } - return; - } - if (event.key === 'Tab' && !inside) { + this.#renderer.listen(this.#document, 'keyup', (event: KeyboardEvent) => { + const target = event.composedPath()[0] as Node; + if ( + event.key === 'Tab' && + this.autoClose() !== false && + !this.#elementRef.nativeElement.contains(target) && + !this.#togglerElement()?.contains(target) + ) { this.setVisibleState(false); } }) ); } + readonly #onEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || this.autoClose() === false) { + return; + } + event.stopPropagation(); + const focusInMenu = this._menuElementRef()?.nativeElement.contains(this.#document.activeElement); + this.setVisibleState(false); + if (focusInMenu) { + this.#focusToggler(); + } + }; + #togglerElement(): HTMLElement | undefined { return (this._toggler() ?? this.toggler)?.elementRef.nativeElement; } #focusToggler(): void { const toggler = this.#togglerElement(); - toggler?.focus(); - if (toggler && this.#document.activeElement !== toggler) { - toggler.querySelector(INTERACTIVE_SELECTOR)?.focus(); + if (!toggler) { + return; + } + const candidates = [toggler, ...toggler.querySelectorAll(FOCUSABLE_SELECTOR)]; + for (const candidate of candidates) { + if (candidate.getAttribute('aria-hidden') === 'true') { + continue; + } + candidate.focus(); + if (this.#document.activeElement === candidate) { + return; + } } } From 9e4677688c46d66776b7af79506cb776a00e7ef5 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 18:38:54 +0200 Subject: [PATCH 12/17] fix(Dropdown): third adversary round - Tab close check walks composedPath, Escape focus return works inside a shadow root - focus return skips tabindex=-1 and aria-hidden candidates, stops at the first that takes focus - contenteditable=false is not editable - a native-click control inside the toggle keeps its click; disabled toggle gets aria-disabled --- .../dropdown-item.directive.spec.ts | 5 ++ .../src/lib/dropdown/dropdown.utils.spec.ts | 3 ++ .../src/lib/dropdown/dropdown.utils.ts | 2 +- .../dropdown/dropdown.component.spec.ts | 53 ++++++++++++++++++- .../dropdown/dropdown/dropdown.component.ts | 29 +++++----- 5 files changed, 77 insertions(+), 15 deletions(-) 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 a0441bc7..4dcc05ab 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 @@ -91,6 +91,7 @@ describe('DropdownItemDirective', () => { template: `
                              +
                            • Island
                            • Link
                            • @@ -130,5 +131,9 @@ describe('DropdownItemDirective Enter', () => { expect(dropdown.visible()).toBe(true); keyup('plain'); expect(dropdown.visible()).toBe(false); + dropdown.visible.set(true); + fixture.detectChanges(); + keyup('island'); + expect(dropdown.visible()).toBe(false); }); }); diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts index f20c3205..a59cade1 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts @@ -13,6 +13,9 @@ describe('dropdown.utils', () => { expect(isEditableTarget(element(''))).toBe(true); expect(isEditableTarget(element(''))).toBe(true); expect(isEditableTarget(element('
                              x
                              ').querySelector('#x'))).toBe(true); + expect(isEditableTarget(element('
                              y
                              ').querySelector('#y'))).toBe( + false + ); expect(isEditableTarget(element(''))).toBe(true); expect(isEditableTarget(element(''))).toBe(false); expect(isEditableTarget(element(''))).toBe(false); diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts index fd92be8f..54d60812 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts @@ -1,5 +1,5 @@ const EDITABLE_SELECTOR = - 'input:not([type="button"]):not([type="image"]):not([type="reset"]):not([type="submit"]), select, textarea, [contenteditable]'; + 'input:not([type="button"]):not([type="image"]):not([type="reset"]):not([type="submit"]), select, textarea, [contenteditable]:not([contenteditable="false"])'; /** * Whether the event target types or selects natively, so dropdown key handling must leave it alone. 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 adb644f0..eb731cf3 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 @@ -468,10 +468,19 @@ describe('DropdownToggleDirective on anchor', () => { const go = element('go'); go.focus(); expect(await keydown(go, 'Enter')).toBe(false); + const click = new MouseEvent('click', { bubbles: true, cancelable: true }); + go.dispatchEvent(click); + fixture.detectChanges(); + expect(click.defaultPrevented).toBe(false); expect(await keydown(go, ' ')).toBe(false); expect(fixture.componentInstance.nestedVisible()).toBe(false); }); + it('should mark a disabled toggle with aria-disabled', () => { + expect(element('disabledAnchor').getAttribute('aria-disabled')).toBe('true'); + expect(element('toggle').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); @@ -560,11 +569,12 @@ describe('DropdownToggleDirective outside the dropdown', () => { template: `
                              - - + + +
                              • @@ -638,3 +648,42 @@ describe('DropdownComponent nested', () => { 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); + host.remove(); + }); +}); 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 9b5ec855..0e90d4bd 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -33,8 +33,7 @@ import { DropdownMenuDirective } from '../dropdown-menu/dropdown-menu.directive' import { DropdownService } from '../dropdown.service'; import { clicksOnEnter, isEditableTarget } from '../dropdown.utils'; -const FOCUSABLE_SELECTOR = - 'a[href], button:not([disabled]), input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; +const FOCUSABLE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]'; // lightweight injection token export abstract class DropdownToken {} @@ -45,6 +44,7 @@ export abstract class DropdownToken {} exportAs: 'cDropdownToggle', host: { '[class]': 'hostClasses()', + '[attr.aria-disabled]': 'disabled() || null', '[aria-expanded]': 'ariaExpanded', '(click)': 'onClick($event)', '(keydown)': 'onKeyDown($event)' @@ -102,11 +102,15 @@ export class DropdownToggleDirective implements AfterViewInit { } public onClick($event: MouseEvent): void { + const element: HTMLElement = this.elementRef.nativeElement; + const target = $event.target as Element; + if (target !== element && clicksOnEnter(target)) { + return; + } $event.preventDefault(); if (this.disabled()) { return; } - const element: HTMLElement = this.elementRef.nativeElement; if (!element.contains(element.ownerDocument.activeElement)) { element.focus(); } @@ -461,12 +465,13 @@ export class DropdownComponent implements OnDestroy, OnInit { } this.listeners.push( this.#renderer.listen(this.#document, 'keyup', (event: KeyboardEvent) => { - const target = event.composedPath()[0] as Node; + const path = event.composedPath(); + const toggler = this.#togglerElement(); if ( event.key === 'Tab' && this.autoClose() !== false && - !this.#elementRef.nativeElement.contains(target) && - !this.#togglerElement()?.contains(target) + !path.includes(this.#elementRef.nativeElement) && + !(toggler && path.includes(toggler)) ) { this.setVisibleState(false); } @@ -479,7 +484,7 @@ export class DropdownComponent implements OnDestroy, OnInit { return; } event.stopPropagation(); - const focusInMenu = this._menuElementRef()?.nativeElement.contains(this.#document.activeElement); + const focusInMenu = this._menuElementRef()?.nativeElement.contains(event.target as Node); this.setVisibleState(false); if (focusInMenu) { this.#focusToggler(); @@ -495,13 +500,13 @@ export class DropdownComponent implements OnDestroy, OnInit { if (!toggler) { return; } - const candidates = [toggler, ...toggler.querySelectorAll(FOCUSABLE_SELECTOR)]; + 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) { - if (candidate.getAttribute('aria-hidden') === 'true') { - continue; - } candidate.focus(); - if (this.#document.activeElement === candidate) { + if (root.activeElement === candidate) { return; } } From 5e828cf66b4cc651f63da38852a51307891349f4 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 18:48:55 +0200 Subject: [PATCH 13/17] fix(Dropdown): fourth adversary round - toggle click contract back to base: any click bubbling to the toggle toggles, no aria-disabled from the input - editable target: nearest contenteditable ancestor decides, value compared case-insensitively - shadow spec asserts Tab-out closes --- .../dropdown-item.directive.spec.ts | 4 ++- .../src/lib/dropdown/dropdown.utils.spec.ts | 17 +++++++++++ .../src/lib/dropdown/dropdown.utils.ts | 16 +++++++--- .../dropdown/dropdown.component.spec.ts | 30 ++++++++++++++----- .../dropdown/dropdown/dropdown.component.ts | 7 +---- 5 files changed, 56 insertions(+), 18 deletions(-) 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 4dcc05ab..87e155cb 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 @@ -91,7 +91,9 @@ describe('DropdownItemDirective', () => { template: `
                                  -
                                • Island
                                • +
                                • +
                                  Island
                                  +
                                • Link
                                • diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts index a59cade1..a1fde3ea 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts @@ -16,6 +16,23 @@ describe('dropdown.utils', () => { expect(isEditableTarget(element('
                                  y
                                  ').querySelector('#y'))).toBe( false ); + expect( + isEditableTarget( + element('
                                  z
                                  ').querySelector( + '#z' + ) + ) + ).toBe(false); + expect(isEditableTarget(element('
                                  w
                                  ').querySelector('#w'))).toBe( + false + ); + expect( + isEditableTarget( + element('
                                  v
                                  ').querySelector( + '#v' + ) + ) + ).toBe(true); expect(isEditableTarget(element(''))).toBe(true); expect(isEditableTarget(element(''))).toBe(false); expect(isEditableTarget(element(''))).toBe(false); diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts index 54d60812..e5d289f9 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts @@ -1,11 +1,19 @@ -const EDITABLE_SELECTOR = - 'input:not([type="button"]):not([type="image"]):not([type="reset"]):not([type="submit"]), select, textarea, [contenteditable]:not([contenteditable="false"])'; +const FORM_CONTROL_SELECTOR = + 'input:not([type="button"]):not([type="image"]):not([type="reset"]):not([type="submit"]), select, textarea'; /** * Whether the event target types or selects natively, so dropdown key handling must leave it alone. */ -export const isEditableTarget = (target: EventTarget | null): boolean => - target instanceof Element && target.closest(EDITABLE_SELECTOR) !== null; +export const isEditableTarget = (target: EventTarget | null): boolean => { + if (!(target instanceof Element)) { + return false; + } + if (target.closest(FORM_CONTROL_SELECTOR)) { + return true; + } + const editable = target.closest('[contenteditable]'); + return editable !== null && editable.getAttribute('contenteditable')?.toLowerCase() !== 'false'; +}; /** * Whether the element dispatches a native click on Enter. 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 eb731cf3..9f52f719 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 @@ -468,17 +468,12 @@ describe('DropdownToggleDirective on anchor', () => { const go = element('go'); go.focus(); expect(await keydown(go, 'Enter')).toBe(false); - const click = new MouseEvent('click', { bubbles: true, cancelable: true }); - go.dispatchEvent(click); - fixture.detectChanges(); - expect(click.defaultPrevented).toBe(false); expect(await keydown(go, ' ')).toBe(false); expect(fixture.componentInstance.nestedVisible()).toBe(false); }); - it('should mark a disabled toggle with aria-disabled', () => { - expect(element('disabledAnchor').getAttribute('aria-disabled')).toBe('true'); - expect(element('toggle').hasAttribute('aria-disabled')).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 () => { @@ -588,6 +583,18 @@ class FieldToggleComponent { } 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); @@ -684,6 +691,15 @@ describe('DropdownComponent with shadow DOM', () => { 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(); }); }); 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 0e90d4bd..f9028787 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -44,7 +44,6 @@ export abstract class DropdownToken {} exportAs: 'cDropdownToggle', host: { '[class]': 'hostClasses()', - '[attr.aria-disabled]': 'disabled() || null', '[aria-expanded]': 'ariaExpanded', '(click)': 'onClick($event)', '(keydown)': 'onKeyDown($event)' @@ -102,15 +101,11 @@ export class DropdownToggleDirective implements AfterViewInit { } public onClick($event: MouseEvent): void { - const element: HTMLElement = this.elementRef.nativeElement; - const target = $event.target as Element; - if (target !== element && clicksOnEnter(target)) { - return; - } $event.preventDefault(); if (this.disabled()) { return; } + const element: HTMLElement = this.elementRef.nativeElement; if (!element.contains(element.ownerDocument.activeElement)) { element.focus(); } From 9d852536fa2136c9afcc5db6deb2f6c8ed21f3f7 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 18:55:53 +0200 Subject: [PATCH 14/17] fix(Dropdown): fifth adversary round - contenteditable ancestors walked until a valid value; invalid values inherit - key target read from composedPath, so a field inside a shadow root keeps its keys --- .../dropdown-item/dropdown-item.directive.ts | 2 +- .../dropdown-menu.directive.spec.ts | 27 +++++++++++++++++++ .../dropdown-menu/dropdown-menu.directive.ts | 2 +- .../src/lib/dropdown/dropdown.utils.spec.ts | 17 ++++++++++++ .../src/lib/dropdown/dropdown.utils.ts | 16 +++++++++-- .../dropdown/dropdown.component.spec.ts | 3 +-- .../dropdown/dropdown/dropdown.component.ts | 2 +- 7 files changed, 62 insertions(+), 7 deletions(-) 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 228f59bb..c9b52a96 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 @@ -98,7 +98,7 @@ export class DropdownItemDirective implements FocusableOption { } onKeyUp($event: KeyboardEvent): void { - const target = $event.target as Element; + const target = $event.composedPath()[0] as Element; if ($event.key === 'Enter' && !isEditableTarget(target) && !clicksOnEnter(target)) { this.handleInteraction(); } 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 2e3d2712..afdcce44 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 @@ -168,6 +168,33 @@ describe('DropdownMenuDirective Space', () => { expect(document.activeElement).toBe(menu.querySelector('#link')); }); + it('should leave every key to a form control inside a shadow root in the menu', () => { + const fixture = TestBed.createComponent(SpaceTestComponent); + fixture.detectChanges(); + const menu = fixture.debugElement.query(By.directive(DropdownMenuDirective)).nativeElement; + const host = document.createElement('x-field'); + menu.querySelector('li').append(host); + const field = document.createElement('input'); + host.attachShadow({ mode: 'open' }).append(field); + field.focus(); + for (const [key, code, keyCode] of [ + [' ', 'Space', 32], + ['ArrowDown', 'ArrowDown', 40] + ] as const) { + const event = new KeyboardEvent('keydown', { + key, + code, + keyCode, + bubbles: true, + composed: true, + cancelable: true + }); + field.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(host.shadowRoot?.activeElement).toBe(field); + } + }); + it('should leave every key to a form control inside the menu', () => { const fixture = TestBed.createComponent(SpaceTestComponent); fixture.detectChanges(); 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 c7186aee..dc544adb 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 @@ -83,7 +83,7 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { readonly dataPopper = computed(() => (this.#dropdownService.popper() ? null : 'static')); onKeyDown($event: KeyboardEvent): void { - if (!this.visible() || isEditableTarget($event.target)) { + if (!this.visible() || isEditableTarget($event.composedPath()[0])) { return; } if ($event.code === 'ArrowDown' || ($event.code === 'Space' && !this.#clicksOnSpace($event.target))) { diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts index a1fde3ea..ed9b2a41 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts @@ -26,6 +26,23 @@ describe('dropdown.utils', () => { expect(isEditableTarget(element('
                                  w
                                  ').querySelector('#w'))).toBe( false ); + expect( + isEditableTarget( + element( + '
                                  u
                                  ' + ).querySelector('#u') + ) + ).toBe(false); + expect(isEditableTarget(element('
                                  t
                                  ').querySelector('#t'))).toBe( + false + ); + expect( + isEditableTarget( + element( + '
                                  s
                                  ' + ).querySelector('#s') + ) + ).toBe(true); expect( isEditableTarget( element('
                                  v
                                  ').querySelector( diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts index e5d289f9..a3fb802d 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts @@ -11,8 +11,20 @@ export const isEditableTarget = (target: EventTarget | null): boolean => { if (target.closest(FORM_CONTROL_SELECTOR)) { return true; } - const editable = target.closest('[contenteditable]'); - return editable !== null && editable.getAttribute('contenteditable')?.toLowerCase() !== 'false'; + for ( + let host = target.closest('[contenteditable]'); + host; + host = host.parentElement?.closest('[contenteditable]') ?? null + ) { + const value = host.getAttribute('contenteditable')?.toLowerCase(); + if (value === '' || value === 'true' || value === 'plaintext-only') { + return true; + } + if (value === 'false') { + return false; + } + } + return false; }; /** 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 9f52f719..6e554cec 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 @@ -464,12 +464,11 @@ describe('DropdownToggleDirective on anchor', () => { expect(element('nested').getAttribute('role')).toBe('button'); }); - it('should leave Enter and Space to a native control inside an anchor toggle', async () => { + 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); - expect(fixture.componentInstance.nestedVisible()).toBe(false); }); it('should not expose aria-disabled from the directive input', () => { 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 f9028787..11031ac1 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -113,7 +113,7 @@ export class DropdownToggleDirective implements AfterViewInit { } onKeyDown($event: KeyboardEvent): void { - if (this.disabled() || isEditableTarget($event.target)) { + if (this.disabled() || isEditableTarget($event.composedPath()[0])) { return; } const element: HTMLElement = this.elementRef.nativeElement; From 371a533bd28de197e227a1cd737a85c64f880e38 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 19:02:55 +0200 Subject: [PATCH 15/17] fix(Dropdown): sixth adversary round - key target read from event.target again: composedPath throws during Angular event replay; it stays only in the document Tab listener - specs: contenteditable TRUE, plaintext-only and a False island under an editor; autoClose false keeps Escape and Tab inert --- .../dropdown-item/dropdown-item.directive.ts | 2 +- .../dropdown-menu.directive.spec.ts | 27 -------------- .../dropdown-menu/dropdown-menu.directive.ts | 2 +- .../src/lib/dropdown/dropdown.utils.spec.ts | 13 +++++++ .../dropdown/dropdown.component.spec.ts | 36 +++++++++++++++++++ .../dropdown/dropdown/dropdown.component.ts | 2 +- 6 files changed, 52 insertions(+), 30 deletions(-) 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 c9b52a96..228f59bb 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 @@ -98,7 +98,7 @@ export class DropdownItemDirective implements FocusableOption { } onKeyUp($event: KeyboardEvent): void { - const target = $event.composedPath()[0] as Element; + const target = $event.target as Element; if ($event.key === 'Enter' && !isEditableTarget(target) && !clicksOnEnter(target)) { this.handleInteraction(); } 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 afdcce44..2e3d2712 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 @@ -168,33 +168,6 @@ describe('DropdownMenuDirective Space', () => { expect(document.activeElement).toBe(menu.querySelector('#link')); }); - it('should leave every key to a form control inside a shadow root in the menu', () => { - const fixture = TestBed.createComponent(SpaceTestComponent); - fixture.detectChanges(); - const menu = fixture.debugElement.query(By.directive(DropdownMenuDirective)).nativeElement; - const host = document.createElement('x-field'); - menu.querySelector('li').append(host); - const field = document.createElement('input'); - host.attachShadow({ mode: 'open' }).append(field); - field.focus(); - for (const [key, code, keyCode] of [ - [' ', 'Space', 32], - ['ArrowDown', 'ArrowDown', 40] - ] as const) { - const event = new KeyboardEvent('keydown', { - key, - code, - keyCode, - bubbles: true, - composed: true, - cancelable: true - }); - field.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); - expect(host.shadowRoot?.activeElement).toBe(field); - } - }); - it('should leave every key to a form control inside the menu', () => { const fixture = TestBed.createComponent(SpaceTestComponent); fixture.detectChanges(); 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 dc544adb..c7186aee 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 @@ -83,7 +83,7 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { readonly dataPopper = computed(() => (this.#dropdownService.popper() ? null : 'static')); onKeyDown($event: KeyboardEvent): void { - if (!this.visible() || isEditableTarget($event.composedPath()[0])) { + if (!this.visible() || isEditableTarget($event.target)) { return; } if ($event.code === 'ArrowDown' || ($event.code === 'Space' && !this.#clicksOnSpace($event.target))) { diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts index ed9b2a41..703be14c 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts @@ -36,6 +36,19 @@ describe('dropdown.utils', () => { expect(isEditableTarget(element('
                                  t
                                  ').querySelector('#t'))).toBe( false ); + expect(isEditableTarget(element('
                                  c
                                  ').querySelector('#c'))).toBe( + true + ); + expect( + isEditableTarget(element('
                                  p
                                  ').querySelector('#p')) + ).toBe(true); + expect( + isEditableTarget( + element('
                                  d
                                  ').querySelector( + '#d' + ) + ) + ).toBe(false); expect( isEditableTarget( element( 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 6e554cec..88c9dba3 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 @@ -702,3 +702,39 @@ describe('DropdownComponent with shadow DOM', () => { 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); + }); +}); 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 11031ac1..f9028787 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -113,7 +113,7 @@ export class DropdownToggleDirective implements AfterViewInit { } onKeyDown($event: KeyboardEvent): void { - if (this.disabled() || isEditableTarget($event.composedPath()[0])) { + if (this.disabled() || isEditableTarget($event.target)) { return; } const element: HTMLElement = this.elementRef.nativeElement; From 44147ec392eb7b5643dbd6fed61a7bf81f3fbfca Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 19:09:40 +0200 Subject: [PATCH 16/17] fix(Dropdown): seventh adversary round - replayed events (Angular event replay after hydration) are ignored on the toggle and the menu, nothing can be prevented after dispatch - toggle keydown stops propagation for the keys it handles, so a toggle inside another menu does not move that menu --- .../dropdown-menu/dropdown-menu.directive.ts | 4 +- .../src/lib/dropdown/dropdown.utils.spec.ts | 10 ++- .../src/lib/dropdown/dropdown.utils.ts | 7 ++ .../dropdown/dropdown.component.spec.ts | 81 +++++++++++++++++++ .../dropdown/dropdown/dropdown.component.ts | 6 +- 5 files changed, 103 insertions(+), 5 deletions(-) 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 c7186aee..014d9a7b 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 @@ -22,7 +22,7 @@ import { ThemeDirective } from '../../shared/theme.directive'; import { DropdownItemDirective } from '../dropdown-item/dropdown-item.directive'; import { BreakpointInfix, DropdownAlignment } from '../../coreui.types'; import { DropdownService } from '../dropdown.service'; -import { clicksOnSpace, isEditableTarget } from '../dropdown.utils'; +import { clicksOnSpace, isEditableTarget, isReplayedEvent } from '../dropdown.utils'; @Directive({ selector: '[cDropdownMenu]', @@ -83,7 +83,7 @@ export class DropdownMenuDirective implements OnInit, AfterContentInit { readonly dataPopper = computed(() => (this.#dropdownService.popper() ? null : 'static')); onKeyDown($event: KeyboardEvent): void { - if (!this.visible() || isEditableTarget($event.target)) { + if (!this.visible() || isReplayedEvent($event) || isEditableTarget($event.target)) { return; } if ($event.code === 'ArrowDown' || ($event.code === 'Space' && !this.#clicksOnSpace($event.target))) { diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts index 703be14c..5e9b2886 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.spec.ts @@ -1,5 +1,5 @@ /// -import { clicksOnEnter, clicksOnSpace, isEditableTarget } from './dropdown.utils'; +import { clicksOnEnter, clicksOnSpace, isEditableTarget, isReplayedEvent } from './dropdown.utils'; const element = (html: string): Element => { const template = document.createElement('template'); @@ -96,4 +96,12 @@ describe('dropdown.utils', () => { expect(clicksOnSpace(element('a'))).toBe(false); expect(clicksOnSpace(element(''))).toBe(false); }); + + it('isReplayedEvent should recognise the replay phase', () => { + const live = new KeyboardEvent('keydown'); + const replay = new KeyboardEvent('keydown'); + Object.defineProperty(replay, 'eventPhase', { value: 101 }); + expect(isReplayedEvent(live)).toBe(false); + expect(isReplayedEvent(replay)).toBe(true); + }); }); diff --git a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts index a3fb802d..785905ea 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown.utils.ts @@ -1,3 +1,10 @@ +const REPLAY_EVENT_PHASE = 101; + +/** + * Whether Angular is replaying the event after hydration (`EventPhase.REPLAY`); such an event cannot be prevented. + */ +export const isReplayedEvent = (event: Event): boolean => event.eventPhase === REPLAY_EVENT_PHASE; + const FORM_CONTROL_SELECTOR = 'input:not([type="button"]):not([type="image"]):not([type="reset"]):not([type="submit"]), select, textarea'; 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 88c9dba3..639099bf 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 @@ -738,3 +738,84 @@ describe('DropdownComponent with autoClose false', () => { 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, the menu and an item without throwing', async () => { + const fixture = TestBed.createComponent(KeyboardTestComponent); + fixture.detectChanges(); + await fixture.whenStable(); + const toggle: HTMLElement = fixture.nativeElement.querySelector('#toggle'); + expect(() => toggle.dispatchEvent(replayed('keydown', { key: 'ArrowDown', keyCode: 40 }))).not.toThrow(); + expect(fixture.componentInstance.visible()).toBe(false); + fixture.componentInstance.visible.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + const items: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.dropdown-item')]; + items[0].focus(); + expect(() => items[0].dispatchEvent(replayed('keydown', { key: 'ArrowDown', keyCode: 40 }))).not.toThrow(); + expect(document.activeElement).toBe(items[0]); + expect(() => items[0].dispatchEvent(replayed('keyup', { key: 'Enter' }))).not.toThrow(); + }); +}); + +@Component({ + template: ` + + +
                                    +
                                  • +
                                  • + + +
                                      +
                                    • +
                                    +
                                    +
                                  • +
                                  • +
                                  +
                                  + `, + imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective] +}) +class SubmenuComponent { + readonly inner = signal(false); + readonly outer = signal(true); +} + +describe('DropdownComponent with a toggle inside another menu', () => { + it('should let only the inner dropdown handle the arrow', async () => { + const fixture = TestBed.createComponent(SubmenuComponent); + fixture.detectChanges(); + await fixture.whenStable(); + const sub: HTMLElement = fixture.nativeElement.querySelector('#sub'); + const focused: string[] = []; + fixture.nativeElement.addEventListener('focusin', (event: FocusEvent) => + focused.push((event.target as Element).id) + ); + sub.focus(); + focused.length = 0; + sub.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', keyCode: 38, bubbles: true, cancelable: true })); + fixture.detectChanges(); + await fixture.whenStable(); + expect(fixture.componentInstance.inner()).toBe(true); + expect(focused).toEqual(['i1']); + expect(document.activeElement?.id).toBe('i1'); + }); +}); 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 f9028787..50f85a43 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -31,7 +31,7 @@ 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 } from '../dropdown.utils'; +import { clicksOnEnter, isEditableTarget, isReplayedEvent } from '../dropdown.utils'; const FOCUSABLE_SELECTOR = 'a[href], button, input, select, textarea, [tabindex]'; @@ -113,7 +113,7 @@ export class DropdownToggleDirective implements AfterViewInit { } onKeyDown($event: KeyboardEvent): void { - if (this.disabled() || isEditableTarget($event.target)) { + if (this.disabled() || isReplayedEvent($event) || isEditableTarget($event.target)) { return; } const element: HTMLElement = this.elementRef.nativeElement; @@ -123,6 +123,7 @@ export class DropdownToggleDirective implements AfterViewInit { ($event.key === ' ' || ($event.key === 'Enter' && !clicksOnEnter(element))) ) { $event.preventDefault(); + $event.stopPropagation(); if (!$event.repeat) { element.click(); } @@ -132,6 +133,7 @@ export class DropdownToggleDirective implements AfterViewInit { return; } $event.preventDefault(); + $event.stopPropagation(); this.#dropdownService.toggle({ visible: true, dropdown: this.dropdown, From 25e1ecc601a675753645647f526dbe71a7e1d809 Mon Sep 17 00:00:00 2001 From: xidedix Date: Wed, 7 Oct 2026 19:18:05 +0200 Subject: [PATCH 17/17] fix(Dropdown): eighth adversary round - toggle keydown no longer stops propagation: a nested toggle lets the parent menu move as in vanilla and React - replay spec watches ErrorHandler and the service instead of the swallowed listener throw --- .../dropdown/dropdown.component.spec.ts | 61 +++---------------- .../dropdown/dropdown/dropdown.component.ts | 2 - 2 files changed, 9 insertions(+), 54 deletions(-) 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 639099bf..68ecc211 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,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { DropdownComponent, DropdownToggleDirective } from './dropdown.component'; -import { Component, DebugElement, DOCUMENT, ElementRef, input, Renderer2, signal } from '@angular/core'; +import { Component, DebugElement, DOCUMENT, ElementRef, ErrorHandler, input, Renderer2, signal } from '@angular/core'; import { DropdownAlignment } from '../../coreui.types'; import { DropdownService } from '../dropdown.service'; import { By } from '@angular/platform-browser'; @@ -756,66 +756,23 @@ const replayed = (type: string, init: KeyboardEventInit): KeyboardEvent => { }; describe('DropdownComponent with replayed events', () => { - it('should ignore a replayed key on the toggle, the menu and an item without throwing', async () => { + 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'); - expect(() => toggle.dispatchEvent(replayed('keydown', { key: 'ArrowDown', keyCode: 40 }))).not.toThrow(); - expect(fixture.componentInstance.visible()).toBe(false); + 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(); - expect(() => items[0].dispatchEvent(replayed('keydown', { key: 'ArrowDown', keyCode: 40 }))).not.toThrow(); + items[0].dispatchEvent(replayed('keydown', { key: 'ArrowDown', keyCode: 40 })); expect(document.activeElement).toBe(items[0]); - expect(() => items[0].dispatchEvent(replayed('keyup', { key: 'Enter' }))).not.toThrow(); - }); -}); - -@Component({ - template: ` - - -
                                    -
                                  • -
                                  • - - -
                                      -
                                    • -
                                    -
                                    -
                                  • -
                                  • -
                                  -
                                  - `, - imports: [DropdownToggleDirective, DropdownComponent, DropdownMenuDirective, DropdownItemDirective] -}) -class SubmenuComponent { - readonly inner = signal(false); - readonly outer = signal(true); -} - -describe('DropdownComponent with a toggle inside another menu', () => { - it('should let only the inner dropdown handle the arrow', async () => { - const fixture = TestBed.createComponent(SubmenuComponent); - fixture.detectChanges(); - await fixture.whenStable(); - const sub: HTMLElement = fixture.nativeElement.querySelector('#sub'); - const focused: string[] = []; - fixture.nativeElement.addEventListener('focusin', (event: FocusEvent) => - focused.push((event.target as Element).id) - ); - sub.focus(); - focused.length = 0; - sub.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', keyCode: 38, bubbles: true, cancelable: true })); - fixture.detectChanges(); - await fixture.whenStable(); - expect(fixture.componentInstance.inner()).toBe(true); - expect(focused).toEqual(['i1']); - expect(document.activeElement?.id).toBe('i1'); + 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 50f85a43..24de147e 100644 --- a/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts +++ b/projects/coreui-angular/src/lib/dropdown/dropdown/dropdown.component.ts @@ -123,7 +123,6 @@ export class DropdownToggleDirective implements AfterViewInit { ($event.key === ' ' || ($event.key === 'Enter' && !clicksOnEnter(element))) ) { $event.preventDefault(); - $event.stopPropagation(); if (!$event.repeat) { element.click(); } @@ -133,7 +132,6 @@ export class DropdownToggleDirective implements AfterViewInit { return; } $event.preventDefault(); - $event.stopPropagation(); this.#dropdownService.toggle({ visible: true, dropdown: this.dropdown,