Skip to content
Open
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
1 change: 1 addition & 0 deletions src/aria/combobox/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ ng_project(
deps = [
"//:node_modules/@angular/core",
"//src/aria/private",
"//src/cdk/a11y",
"//src/cdk/bidi",
],
)
Expand Down
23 changes: 22 additions & 1 deletion src/aria/combobox/combobox-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {Directive, ElementRef, inject, input, OnDestroy, OnInit, signal} from '@angular/core';
import {
afterNextRender,
Directive,
ElementRef,
inject,
input,
OnDestroy,
OnInit,
signal,
} from '@angular/core';
import {_IdGenerator} from '@angular/cdk/a11y';
import {COMBOBOX_POPUP} from './combobox-tokens';

/**
Expand All @@ -28,6 +38,7 @@ export class ComboboxWidget implements OnInit, OnDestroy {
/** The element that the popup widget is attached to. */
private readonly _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly _popup = inject(COMBOBOX_POPUP);
private readonly _idGenerator = inject(_IdGenerator);

/** A reference to the popup widget element. */
readonly element = this._elementRef.nativeElement;
Expand All @@ -54,6 +65,16 @@ export class ComboboxWidget implements OnInit, OnDestroy {
attributes: true,
attributeFilter: ['id'],
});

afterNextRender(() => {
// Imperative, not a host binding: the element's id may already be owned by another
// directive (e.g. Listbox, Tree, Grid), so this can't collide with it.
if (!el.id) {
el.id = this._idGenerator.getId('ng-combobox-widget-', true);
}
// Set synchronously; the MutationObserver above only fires on the next microtask.
this.popupId.set(el.id);
});
}

ngOnInit() {
Expand Down
116 changes: 116 additions & 0 deletions src/aria/combobox/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
computed,
DebugElement,
signal,
Type,
untracked,
viewChild,
afterRenderEffect,
Expand Down Expand Up @@ -1235,6 +1236,47 @@ describe('Combobox', () => {
});
});
});

describe('ComboboxWidget', () => {
let fixture: ComponentFixture<unknown>;
let comboboxElement: HTMLElement;
let widgetElement: HTMLElement;

const expand = async (componentType: Type<unknown>) => {
fixture = TestBed.createComponent(componentType);
await fixture.whenStable();
comboboxElement = fixture.debugElement.query(By.directive(Combobox))
.nativeElement as HTMLElement;
comboboxElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
await fixture.whenStable();
comboboxElement.dispatchEvent(
new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true}),
);
await fixture.whenStable();
widgetElement = fixture.debugElement.query(By.directive(ComboboxWidget))
.nativeElement as HTMLElement;
};

afterEach(async () => await runAccessibilityChecks(fixture.nativeElement));

it('should auto-generate an ID on the widget when none is provided', async () => {
await expand(ComboboxDialogExample);
expect(widgetElement.id).toMatch(/^ng-combobox-widget-/);
expect(comboboxElement.getAttribute('aria-controls')).toBe(widgetElement.id);
});

it('should preserve an explicit ID on the widget element', async () => {
await expand(ComboboxDialogCustomIdExample);
expect(widgetElement.id).toBe('custom-id');
expect(comboboxElement.getAttribute('aria-controls')).toBe('custom-id');
});

it('should prioritize sibling directive IDs over generated IDs', async () => {
await expand(ComboboxListboxGeneratedIdExample);
expect(widgetElement.id).toMatch(/^ng-listbox-/);
expect(comboboxElement.getAttribute('aria-controls')).toBe(widgetElement.id);
});
});
});

@Component({
Expand Down Expand Up @@ -1716,3 +1758,77 @@ class ComboboxListboxHighlightExample {
this.popupExpanded.set(false);
}
}

@Component({
template: `
<div>
<div
ngCombobox
#combobox="ngCombobox"
aria-label="Search"
[(expanded)]="popupExpanded"
>{{value()}}</div>

<ng-template ngComboboxPopup [combobox]="combobox" popupType="dialog">
<div ngComboboxWidget>
<input aria-label="Filter" />
</div>
</ng-template>
</div>
`,
imports: [Combobox, ComboboxPopup, ComboboxWidget],
})
class ComboboxDialogExample {
popupExpanded = signal(false);
value = signal('');
}

@Component({
template: `
<div>
<div
ngCombobox
#combobox="ngCombobox"
aria-label="Search"
[(expanded)]="popupExpanded"
>{{value()}}</div>

<ng-template ngComboboxPopup [combobox]="combobox" popupType="dialog">
<div ngComboboxWidget id="custom-id">
<input aria-label="Filter" />
</div>
</ng-template>
</div>
`,
imports: [Combobox, ComboboxPopup, ComboboxWidget],
})
class ComboboxDialogCustomIdExample {
popupExpanded = signal(false);
value = signal('');
}

@Component({
template: `
<div>
<input
ngCombobox
#combobox="ngCombobox"
aria-label="Search"
[(expanded)]="popupExpanded"
/>

<ng-template ngComboboxPopup [combobox]="combobox">
<div ngComboboxWidget ngListbox #listbox="ngListbox" focusMode="activedescendant" [activeDescendant]="listbox.activeDescendant()">
@for (option of options; track option) {
<div ngOption [value]="option" [label]="option">{{option}}</div>
}
</div>
</ng-template>
</div>
`,
imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option],
})
class ComboboxListboxGeneratedIdExample {
popupExpanded = signal(false);
options = ['Apple', 'Banana'];
}
Loading