diff --git a/src/aria/combobox/BUILD.bazel b/src/aria/combobox/BUILD.bazel index d39dc069bf79..62e074472d70 100644 --- a/src/aria/combobox/BUILD.bazel +++ b/src/aria/combobox/BUILD.bazel @@ -11,6 +11,7 @@ ng_project( deps = [ "//:node_modules/@angular/core", "//src/aria/private", + "//src/cdk/a11y", "//src/cdk/bidi", ], ) diff --git a/src/aria/combobox/combobox-widget.ts b/src/aria/combobox/combobox-widget.ts index 3f4636e68ab4..82f33082633a 100644 --- a/src/aria/combobox/combobox-widget.ts +++ b/src/aria/combobox/combobox-widget.ts @@ -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'; /** @@ -28,6 +38,7 @@ export class ComboboxWidget implements OnInit, OnDestroy { /** The element that the popup widget is attached to. */ private readonly _elementRef = inject>(ElementRef); private readonly _popup = inject(COMBOBOX_POPUP); + private readonly _idGenerator = inject(_IdGenerator); /** A reference to the popup widget element. */ readonly element = this._elementRef.nativeElement; @@ -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() { diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index a3ee5b66d957..5f835a5d2a7f 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -3,6 +3,7 @@ import { computed, DebugElement, signal, + Type, untracked, viewChild, afterRenderEffect, @@ -1235,6 +1236,47 @@ describe('Combobox', () => { }); }); }); + + describe('ComboboxWidget', () => { + let fixture: ComponentFixture; + let comboboxElement: HTMLElement; + let widgetElement: HTMLElement; + + const expand = async (componentType: Type) => { + 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({ @@ -1716,3 +1758,77 @@ class ComboboxListboxHighlightExample { this.popupExpanded.set(false); } } + +@Component({ + template: ` +
+
{{value()}}
+ + +
+ +
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget], +}) +class ComboboxDialogExample { + popupExpanded = signal(false); + value = signal(''); +} + +@Component({ + template: ` +
+
{{value()}}
+ + +
+ +
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget], +}) +class ComboboxDialogCustomIdExample { + popupExpanded = signal(false); + value = signal(''); +} + +@Component({ + template: ` +
+ + + +
+ @for (option of options; track option) { +
{{option}}
+ } +
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget, Listbox, Option], +}) +class ComboboxListboxGeneratedIdExample { + popupExpanded = signal(false); + options = ['Apple', 'Banana']; +}