From 2c4daa5be39c93365f53b3aeb65ae0a10184c075 Mon Sep 17 00:00:00 2001 From: Yusef Ouda Date: Thu, 29 Jan 2026 22:05:24 -0600 Subject: [PATCH] fix(typeahead): accessibility fixes - Adds role=combobox and sets aria-controls instead of aria-owns on typeahead. - Ensure `activeChangeEvent` is fired when active item changes by using setter Fixes #6468, #6647 --- .../testing/typeahead.directive.spec.ts | 6 ++++ .../typeahead-container.component.ts | 31 ++++++++++--------- src/typeahead/typeahead.directive.ts | 6 ++-- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/typeahead/testing/typeahead.directive.spec.ts b/src/typeahead/testing/typeahead.directive.spec.ts index 0a29feef2a..2fdb88b8d9 100644 --- a/src/typeahead/testing/typeahead.directive.spec.ts +++ b/src/typeahead/testing/typeahead.directive.spec.ts @@ -187,6 +187,12 @@ describe('Directive: Typeahead', () => { expect(() => directive.onBlur()).not.toThrowError(); }); })); + + it('should have appropriate aria attributes', () => { + expect(inputElement.getAttribute('role')).toEqual('combobox'); + expect(inputElement.getAttribute('aria-expanded')).toEqual('false'); + expect(inputElement.getAttribute('aria-autocomplete')).toEqual('list'); + }); }); describe('onFocus', () => { diff --git a/src/typeahead/typeahead-container.component.ts b/src/typeahead/typeahead-container.component.ts index 3447f42092..9344e4cde0 100644 --- a/src/typeahead/typeahead-container.component.ts +++ b/src/typeahead/typeahead-container.component.ts @@ -145,13 +145,13 @@ export class TypeaheadContainerComponent implements OnDestroy { if (this.typeaheadIsFirstItemActive && this._matches.length > 0) { this.setActive(this._matches[0]); - if (this._active?.isHeader()) { + if (this.active?.isHeader()) { this.nextActiveMatch(); } } - if (this._active && !this.typeaheadIsFirstItemActive) { - const concurrency = this._matches.find(match => match.value === this._active?.value); + if (this.active && !this.typeaheadIsFirstItemActive) { + const concurrency = this._matches.find(match => match.value === this.active?.value); if (concurrency) { this.selectActive(concurrency); @@ -200,34 +200,35 @@ export class TypeaheadContainerComponent implements OnDestroy { } selectActiveMatch(isActiveItemChanged?: boolean): void { - if (this._active && this.parent?.typeaheadSelectFirstItem) { - this.selectMatch(this._active); + if (this.active && this.parent?.typeaheadSelectFirstItem) { + this.selectMatch(this.active); } if (!this.parent?.typeaheadSelectFirstItem && isActiveItemChanged) { - this.selectMatch(this._active); + this.selectMatch(this.active); } } activeChanged(): void { - if (!this._active) { + if (!this.active) { + this.activeChangeEvent.emit(void 0); return; } - const index = this.matches.indexOf(this._active); + const index = this.matches.indexOf(this.active); this.activeChangeEvent.emit(`${this.popupId}-${index}`); } prevActiveMatch(): void { - if (!this._active) { + if (!this.active) { return; } - const index = this.matches.indexOf(this._active); + const index = this.matches.indexOf(this.active); this.setActive(this.matches[ index - 1 < 0 ? this.matches.length - 1 : index - 1 ]); - if (this._active.isHeader()) { + if (this.active.isHeader()) { this.prevActiveMatch(); } @@ -237,12 +238,12 @@ export class TypeaheadContainerComponent implements OnDestroy { } nextActiveMatch(): void { - const index = this._active ? this.matches.indexOf(this._active) : -1; + const index = this.active ? this.matches.indexOf(this.active) : -1; this.setActive(this.matches[ index + 1 > this.matches.length - 1 ? 0 : index + 1 ]); - if (this._active?.isHeader()) { + if (this.active?.isHeader()) { this.nextActiveMatch(); } @@ -377,9 +378,9 @@ export class TypeaheadContainerComponent implements OnDestroy { } protected setActive(value?: TypeaheadMatch): void { - this._active = value; + this.active = value; let preview; - if (!(this._active == null || this._active.isHeader())) { + if (!(this.active == null || this.active.isHeader())) { preview = value; } this.parent?.typeaheadOnPreview.emit(preview); diff --git a/src/typeahead/typeahead.directive.ts b/src/typeahead/typeahead.directive.ts index 4a074a05c2..fe62c91c13 100644 --- a/src/typeahead/typeahead.directive.ts +++ b/src/typeahead/typeahead.directive.ts @@ -35,9 +35,10 @@ type TypeaheadOptionArr = TypeaheadOption[] | Observable; exportAs: 'bs-typeahead', host: { '[attr.aria-activedescendant]': 'activeDescendant', - '[attr.aria-owns]': 'isOpen ? this._container.popupId : null', + '[attr.aria-controls]': 'isOpen ? this._container.popupId : null', '[attr.aria-expanded]': 'isOpen', - '[attr.aria-autocomplete]': 'list' + '[attr.aria-autocomplete]': 'list', + '[attr.role]': `'combobox'` }, standalone: true, providers: [ComponentLoaderFactory, PositioningService] @@ -427,6 +428,7 @@ export class TypeaheadDirective implements OnInit, OnDestroy { this._outsideClickListener(); this._container = void 0; this.isOpen = false; + this.activeDescendant = void 0; this.changeDetection.markForCheck(); } this.typeaheadOnPreview.emit();