Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -85,3 +86,56 @@ describe('DropdownItemDirective', () => {
expect(document.activeElement).toBe(elementRef.nativeElement);
});
});

@Component({
template: `
<c-dropdown visible>
<ul cDropdownMenu>
<li contenteditable="">
<span contenteditable="false"><div cDropdownItem id="island">Island</div></span>
</li>
<li><button cDropdownItem id="button">Action</button></li>
<li><a cDropdownItem href="#" id="link">Link</a></li>
<li><input type="button" cDropdownItem id="inputButton" value="Go" /></li>
<li cDropdownItem id="host"><button id="inner">Inner</button></li>
<li cDropdownItem id="fieldHost"><input id="query" /></li>
<li><div cDropdownItem id="plain">Plain</div></li>
</ul>
</c-dropdown>
`,
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);
});
});
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 });

Expand Down Expand Up @@ -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<string>('list-item');
readonly role = input<string>();

/**
* Tab index of the dropdown-item.
Expand All @@ -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(() => {
Expand All @@ -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();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
<c-dropdown visible>
<button cDropdownToggle>Dropdown button</button>
<ul cDropdownMenu>
<li><button cDropdownItem id="button">Action</button></li>
<li><input type="button" cDropdownItem id="inputItem" value="Go" /></li>
<li><a cDropdownItem href="#" id="link">Link</a></li>
<li><input id="field" /></li>
<li><input type="checkbox" id="check" /></li>
</ul>
</c-dropdown>
`,
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);
}
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FocusKeyManager } from '@angular/cdk/a11y';
import {
AfterContentInit,
afterNextRender,
booleanAttribute,
computed,
contentChildren,
Expand All @@ -9,6 +10,7 @@ import {
ElementRef,
forwardRef,
inject,
Injector,
input,
linkedSignal,
OnInit
Expand All @@ -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]',
Expand All @@ -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<DropdownItemDirective>;
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DropdownAlignment } from '../coreui.types';
export interface IDropdownState {
visible?: boolean | 'toggle';
dropdown?: any;
focus?: 'first' | 'last';
}

@Injectable({
Expand Down
Loading
Loading