From 01d4fda9d9f5d51e21393fc2da355f69c0020046 Mon Sep 17 00:00:00 2001 From: lazerg Date: Wed, 5 Aug 2026 13:14:07 +0500 Subject: [PATCH 1/3] fix(aria/combobox): empty aria-controls when the popup widget has no id --- src/aria/combobox/BUILD.bazel | 1 + src/aria/combobox/combobox-widget.ts | 20 +++++++- src/aria/combobox/combobox.spec.ts | 71 ++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) 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..14d2a08374c1 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,13 @@ export class ComboboxWidget implements OnInit, OnDestroy { attributes: true, attributeFilter: ['id'], }); + + afterNextRender(() => { + if (!el.id) { + el.id = this._idGenerator.getId('ng-combobox-widget-', true); + } + this.popupId.set(el.id); + }); } ngOnInit() { diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index a3ee5b66d957..bc1784a58d53 100644 --- a/src/aria/combobox/combobox.spec.ts +++ b/src/aria/combobox/combobox.spec.ts @@ -1235,6 +1235,53 @@ describe('Combobox', () => { }); }); }); + + describe('with Dialog', () => { + let fixture: ComponentFixture; + let comboboxElement: HTMLElement; + + const focus = async () => { + comboboxElement.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + await fixture.whenStable(); + }; + + const keydown = async (key: string, modifierKeys: {} = {}) => { + await focus(); + comboboxElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + await fixture.whenStable(); + }; + + const down = async (modifierKeys?: {}) => await keydown('ArrowDown', modifierKeys); + + beforeEach(async () => { + fixture = TestBed.createComponent(ComboboxDialogExample); + await fixture.whenStable(); + comboboxElement = fixture.debugElement.query(By.directive(Combobox)) + .nativeElement as HTMLElement; + }); + + afterEach(async () => await runAccessibilityChecks(fixture.nativeElement)); + + describe('ARIA attributes and roles', () => { + it('should have aria-haspopup set to dialog', async () => { + await focus(); + expect(comboboxElement.getAttribute('aria-haspopup')).toBe('dialog'); + }); + + it('should set aria-controls to the widget id', async () => { + await down(); + const widget = fixture.debugElement.query(By.directive(ComboboxWidget)).nativeElement; + expect(widget.id).toBeTruthy(); + expect(comboboxElement.getAttribute('aria-controls')).toBe(widget.id); + }); + }); + }); }); @Component({ @@ -1716,3 +1763,27 @@ class ComboboxListboxHighlightExample { this.popupExpanded.set(false); } } + +@Component({ + template: ` +
+
{{value()}}
+ + +
+ +
+
+
+ `, + imports: [Combobox, ComboboxPopup, ComboboxWidget], +}) +class ComboboxDialogExample { + popupExpanded = signal(false); + value = signal(''); +} From 70a10842150caa589ee7ad64b6c669b6a1cd8fd3 Mon Sep 17 00:00:00 2001 From: lazerg Date: Wed, 5 Aug 2026 13:31:22 +0500 Subject: [PATCH 2/3] docs(aria/combobox): explain the imperative id assignment --- src/aria/combobox/combobox-widget.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/aria/combobox/combobox-widget.ts b/src/aria/combobox/combobox-widget.ts index 14d2a08374c1..82f33082633a 100644 --- a/src/aria/combobox/combobox-widget.ts +++ b/src/aria/combobox/combobox-widget.ts @@ -67,9 +67,12 @@ export class ComboboxWidget implements OnInit, OnDestroy { }); 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); }); } From da624a1e9982c4b76635cfa2f696f9f5c61b106b Mon Sep 17 00:00:00 2001 From: lazerg Date: Thu, 6 Aug 2026 13:23:56 +0500 Subject: [PATCH 3/3] test(aria/combobox): describe the widget id behaviors individually --- src/aria/combobox/combobox.spec.ts | 107 ++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 31 deletions(-) diff --git a/src/aria/combobox/combobox.spec.ts b/src/aria/combobox/combobox.spec.ts index bc1784a58d53..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, @@ -1236,50 +1237,44 @@ describe('Combobox', () => { }); }); - describe('with Dialog', () => { - let fixture: ComponentFixture; + describe('ComboboxWidget', () => { + let fixture: ComponentFixture; let comboboxElement: HTMLElement; + let widgetElement: HTMLElement; - const focus = async () => { + 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(); - }; - - const keydown = async (key: string, modifierKeys: {} = {}) => { - await focus(); comboboxElement.dispatchEvent( - new KeyboardEvent('keydown', { - key, - bubbles: true, - ...modifierKeys, - }), + new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true}), ); await fixture.whenStable(); + widgetElement = fixture.debugElement.query(By.directive(ComboboxWidget)) + .nativeElement as HTMLElement; }; - const down = async (modifierKeys?: {}) => await keydown('ArrowDown', modifierKeys); + afterEach(async () => await runAccessibilityChecks(fixture.nativeElement)); - beforeEach(async () => { - fixture = TestBed.createComponent(ComboboxDialogExample); - await fixture.whenStable(); - comboboxElement = fixture.debugElement.query(By.directive(Combobox)) - .nativeElement as HTMLElement; + 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); }); - afterEach(async () => await runAccessibilityChecks(fixture.nativeElement)); - - describe('ARIA attributes and roles', () => { - it('should have aria-haspopup set to dialog', async () => { - await focus(); - expect(comboboxElement.getAttribute('aria-haspopup')).toBe('dialog'); - }); + 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 set aria-controls to the widget id', async () => { - await down(); - const widget = fixture.debugElement.query(By.directive(ComboboxWidget)).nativeElement; - expect(widget.id).toBeTruthy(); - expect(comboboxElement.getAttribute('aria-controls')).toBe(widget.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); }); }); }); @@ -1787,3 +1782,53 @@ 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']; +}