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