Skip to content

Commit 0873e52

Browse files
feat(combo, simple-combo): improve combo accessibility #11077, #11077 (#11714)
1 parent 1353767 commit 0873e52

11 files changed

+133
-130
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes for each version of this project will be documented in this
44

55
## 14.0.0
66
- `IgxDatePicker` and `IgxDateRangePicker` now expose a `weekStart` input property like the `IgxCalendar`
7+
- `IgxCombo` and `IgxSimpleComboComponent`
8+
- The combobox `role`, `aria-haspopup`, `aria-expanded`, `aria-controls` and `aria-labelledby` attributes have been moved from combo wrapper to the combo input. Additionally the `IgxSimpleComboComponent` input is marked with `aria-readonly="false"` and `aria-autocomplete="list"` attributes. The `aria-expanded` attribute is applied to the combo dropdown as well and can be set by the `ariaLabelledBy` property, the combo label or placeholder. The serach input within the combo dropdown is now marked as `role="searchbox"`, `aria-label="search"` and `aria-autocomplete="list"`. The dropdown item container has `aria-activedescendant` attribute to identify the currently active element of the item list. The `IgxCombo` container is also marked as `aria-multiselectable="true"`. The dropdown header items role has been changed to `group`.
9+
- `IgxDropDown`
10+
- The `label` attribute has been changed to `aria-labelledby` and can be set by a latterly added input property `labelledBy`.
711

812
### New Features
913

projects/igniteui-angular/src/lib/combo/combo-dropdown.component.ts

+6
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,15 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I
9191
*/
9292
public onFocus() {
9393
this.focusedItem = this._focusedItem || this.items[0];
94+
this.combo.setActiveDescendant();
9495
}
9596

9697
/**
9798
* @hidden @internal
9899
*/
99100
public onBlur(_evt?) {
100101
this.focusedItem = null;
102+
this.combo.setActiveDescendant();
101103
}
102104

103105
/**
@@ -112,6 +114,7 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I
112114
*/
113115
public navigateFirst() {
114116
this.navigateItem(this.virtDir.igxForOf.findIndex(e => !e.isHeader));
117+
this.combo.setActiveDescendant();
115118
}
116119

117120
/**
@@ -123,6 +126,7 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I
123126
} else {
124127
super.navigatePrev();
125128
}
129+
this.combo.setActiveDescendant();
126130
}
127131

128132

@@ -136,6 +140,7 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I
136140
} else {
137141
super.navigateNext();
138142
}
143+
this.combo.setActiveDescendant();
139144
}
140145

141146
/**
@@ -147,6 +152,7 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I
147152
}
148153
this.comboAPI.set_selected_item(item.itemID);
149154
this._focusedItem = item;
155+
this.combo.setActiveDescendant();
150156
}
151157

152158
/**

projects/igniteui-angular/src/lib/combo/combo-item.component.ts

+7
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ export class IgxComboItemComponent extends IgxDropDownItemComponent {
2626
@HostBinding('style.height.px')
2727
public itemHeight: string | number = '';
2828

29+
@HostBinding('attr.aria-label')
30+
@Input()
31+
public get ariaLabel(): string {
32+
const valueKey = this.comboAPI.valueKey;
33+
return (valueKey !== null && this.value != null) ? this.value[valueKey] : this.value;
34+
}
35+
2936
/** @hidden @internal */
3037
@Input()
3138
public singleMode: boolean;

projects/igniteui-angular/src/lib/combo/combo.common.ts

+19-25
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
DoCheck,
77
ElementRef,
88
EventEmitter,
9+
forwardRef,
910
HostBinding,
1011
HostListener,
1112
Inject,
@@ -30,7 +31,7 @@ import { SortingDirection } from '../data-operations/sorting-strategy';
3031
import { IForOfState, IgxForOfDirective } from '../directives/for-of/for_of.directive';
3132
import { IgxIconService } from '../icon/public_api';
3233
import { IgxInputGroupType, IGX_INPUT_GROUP_TYPE } from '../input-group/inputGroupType';
33-
import { IgxInputDirective, IgxInputGroupComponent, IgxInputState } from '../input-group/public_api';
34+
import { IgxInputDirective, IgxInputGroupComponent, IgxInputState, IgxLabelDirective } from '../input-group/public_api';
3435
import { AbsoluteScrollStrategy, AutoPositionStrategy, OverlaySettings } from '../services/public_api';
3536
import { IgxComboDropDownComponent } from './combo-dropdown.component';
3637
import { IgxComboAPIService } from './combo.api';
@@ -71,6 +72,7 @@ export interface IgxComboBase {
7172
select(item: any): void;
7273
select(itemIDs: any[], clearSelection?: boolean, event?: Event): void;
7374
deselect(...args: [] | [itemIDs: any[], event?: Event]): void;
75+
setActiveDescendant(): void;
7476
}
7577

7678
let NEXT_ID = 0;
@@ -391,35 +393,12 @@ export abstract class IgxComboBaseDirective extends DisplayDensityBase implement
391393
* ```
392394
*/
393395
@Input()
394-
@HostBinding('attr.aria-labelledby')
395396
public ariaLabelledBy: string;
396397

397398
/** @hidden @internal */
398399
@HostBinding('class.igx-combo')
399400
public cssClass = 'igx-combo'; // Independent of display density for the time being
400401

401-
/** @hidden @internal */
402-
@HostBinding(`attr.role`)
403-
public role = 'combobox';
404-
405-
/** @hidden @internal */
406-
@HostBinding('attr.aria-expanded')
407-
public get ariaExpanded(): boolean {
408-
return !this.dropdown.collapsed;
409-
}
410-
411-
/** @hidden @internal */
412-
@HostBinding('attr.aria-haspopup')
413-
public get hasPopUp() {
414-
return 'listbox';
415-
}
416-
417-
/** @hidden @internal */
418-
@HostBinding('attr.aria-owns')
419-
public get ariaOwns() {
420-
return this.dropdown.id;
421-
}
422-
423402
/**
424403
* An @Input property that enabled/disables combo. The default is `false`.
425404
* ```html
@@ -694,6 +673,9 @@ export abstract class IgxComboBaseDirective extends DisplayDensityBase implement
694673
@ContentChild(IgxComboClearIconDirective, { read: TemplateRef })
695674
public clearIconTemplate: TemplateRef<any> = null;
696675

676+
/** @hidden @internal */
677+
@ContentChild(forwardRef(() => IgxLabelDirective), { static: true }) public label: IgxLabelDirective;
678+
697679
/** @hidden @internal */
698680
@ViewChild('inputGroup', { read: IgxInputGroupComponent, static: true })
699681
public inputGroup: IgxInputGroupComponent;
@@ -862,6 +844,9 @@ export abstract class IgxComboBaseDirective extends DisplayDensityBase implement
862844
public filteringOptions: IComboFilteringOptions = {
863845
caseSensitive: false
864846
};
847+
/** @hidden @internal */
848+
public activeDescendant = '';
849+
865850

866851
protected _data = [];
867852
protected _value = '';
@@ -882,7 +867,7 @@ export abstract class IgxComboBaseDirective extends DisplayDensityBase implement
882867
private _itemsMaxHeight = null;
883868
private _overlaySettings: OverlaySettings;
884869
private _groupSortingDirection: SortingDirection = SortingDirection.Asc;
885-
870+
886871
public abstract dropdown: IgxComboDropDownComponent;
887872

888873
public abstract selectionChanging: EventEmitter<any>;
@@ -958,6 +943,9 @@ export abstract class IgxComboBaseDirective extends DisplayDensityBase implement
958943
public toggle(): void {
959944
const overlaySettings = Object.assign({}, this._overlaySettings, this.overlaySettings);
960945
this.dropdown.toggle(overlaySettings);
946+
if (!this.collapsed){
947+
this.setActiveDescendant();
948+
}
961949
}
962950

963951
/**
@@ -971,6 +959,7 @@ export abstract class IgxComboBaseDirective extends DisplayDensityBase implement
971959
public open(): void {
972960
const overlaySettings = Object.assign({}, this._overlaySettings, this.overlaySettings);
973961
this.dropdown.open(overlaySettings);
962+
this.setActiveDescendant();
974963
}
975964

976965
/**
@@ -1147,6 +1136,11 @@ export abstract class IgxComboBaseDirective extends DisplayDensityBase implement
11471136
}
11481137
}
11491138

1139+
/** @hidden @internal */
1140+
public setActiveDescendant() : void {
1141+
this.activeDescendant = this.dropdown.focusedItem?.id || '';
1142+
}
1143+
11501144
/** @hidden @internal */
11511145
public toggleCaseSensitive() {
11521146
this.filteringOptions = { caseSensitive: !this.filteringOptions.caseSensitive };

projects/igniteui-angular/src/lib/combo/combo.component.html

+12-6
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@
88
<ng-container ngProjectAs="igx-hint, [igxHint]">
99
<ng-content select="igx-hint, [igxHint]"></ng-content>
1010
</ng-container>
11-
<input igxInput #comboInput name="comboInput" type="text" [value]="value" readonly [attr.placeholder]="placeholder"
12-
[disabled]="disabled" (blur)="onBlur()" />
11+
<input igxInput #comboInput name="comboInput" type="text" [value]="value" readonly
12+
[attr.placeholder]="placeholder" [disabled]="disabled"
13+
role="combobox" aria-haspopup="listbox"
14+
[attr.aria-expanded]="!this.dropdown.collapsed" [attr.aria-controls]="this.dropdown.listId"
15+
[attr.aria-labelledby]="this.ariaLabelledBy || this.label?.id || this.placeholder"
16+
(blur)="onBlur()" />
1317
<ng-container ngProjectAs="igx-suffix">
1418
<ng-content select="igx-suffix"></ng-content>
1519
</ng-container>
@@ -32,13 +36,14 @@
3236
</igx-suffix>
3337
</igx-input-group>
3438
<igx-combo-drop-down #igxComboDropDown class="igx-combo__drop-down" [displayDensity]="displayDensity"
39+
[labelledBy]="this.ariaLabelledBy || this.label?.id || this.placeholder || ''"
3540
[width]="itemsWidth || '100%'" (opening)="handleOpening($event)" (closing)="handleClosing($event)"
3641
(opened)="handleOpened()" (closed)="handleClosed()">
3742
<igx-input-group *ngIf="displaySearchInput" [displayDensity]="displayDensity" theme="material" class="igx-combo__search">
3843
<input class="igx-combo-input" igxInput #searchInput name="searchInput" autocomplete="off" type="text"
3944
[(ngModel)]="searchValue" (ngModelChange)="handleInputChange($event)" (keyup)="handleKeyUp($event)"
4045
(keydown)="handleKeyDown($event)" (focus)="dropdown.onBlur($event)" [attr.placeholder]="searchPlaceholder"
41-
aria-autocomplete="both" [attr.aria-owns]="dropdown.id" [attr.aria-labelledby]="ariaLabelledBy" />
46+
aria-autocomplete="list" role="searchbox" aria-label="search"/>
4247
<igx-suffix *ngIf="showSearchCaseIcon">
4348
<igx-icon family="imx-icons" name="case-sensitive" [active]="filteringOptions.caseSensitive"
4449
(click)="toggleCaseSensitive()">
@@ -49,12 +54,13 @@
4954
</ng-container>
5055
<div #dropdownItemContainer class="igx-combo__content" [style.overflow]="'hidden'"
5156
[style.maxHeight.px]="itemsMaxHeight" [igxDropDownItemNavigation]="dropdown" (focus)="dropdown.onFocus()"
52-
[tabindex]="dropdown.collapsed ? -1 : 0" role="listbox" [attr.id]="dropdown.id">
53-
<igx-combo-item role="option" [itemHeight]='itemHeight' *igxFor="let item of data
57+
[tabindex]="dropdown.collapsed ? -1 : 0" [attr.id]="dropdown.id" aria-multiselectable="true"
58+
[attr.aria-activedescendant]="this.activeDescendant">
59+
<igx-combo-item [itemHeight]='itemHeight' *igxFor="let item of data
5460
| comboFiltering:filterValue:displayKey:filteringOptions:filterable
5561
| comboGrouping:groupKey:valueKey:groupSortingDirection;
5662
index as rowIndex; containerSize: itemsMaxHeight; scrollOrientation: 'vertical'; itemSize: itemHeight"
57-
[value]="item" [isHeader]="item.isHeader" [index]="rowIndex">
63+
[value]="item" [isHeader]="item.isHeader" [index]="rowIndex" [role]="item.isHeader? 'group' : 'option'">
5864
<ng-container *ngIf="item.isHeader">
5965
<ng-container
6066
*ngTemplateOutlet="headerItemTemplate ? headerItemTemplate : headerItemBase;

projects/igniteui-angular/src/lib/combo/combo.component.spec.ts

+32-47
Original file line numberDiff line numberDiff line change
@@ -842,55 +842,13 @@ describe('igxCombo', () => {
842842
expect(combo.allowCustomValues).toEqual(false);
843843
expect(combo.cssClass).toEqual(CSS_CLASS_COMBO);
844844
expect(combo.type).toEqual('box');
845-
expect(combo.role).toEqual('combobox');
846845
});
847846
it('should apply all appropriate classes on combo initialization', () => {
848847
const comboWrapper = fixture.nativeElement.querySelector(CSS_CLASS_COMBO);
849848
expect(comboWrapper).not.toBeNull();
850-
expect(comboWrapper.attributes.getNamedItem('ng-reflect-placeholder').nodeValue).toEqual('Location');
851-
expect(comboWrapper.attributes.getNamedItem('ng-reflect-value-key').nodeValue).toEqual('field');
852-
expect(comboWrapper.attributes.getNamedItem('ng-reflect-group-key').nodeValue).toEqual('region');
853-
expect(comboWrapper.attributes.getNamedItem('ng-reflect-filterable')).toBeTruthy();
854-
expect(comboWrapper.childElementCount).toEqual(2); // Input Group + Dropdown
855-
expect(comboWrapper.attributes.getNamedItem('class').nodeValue).toEqual(CSS_CLASS_COMBO);
856-
expect(comboWrapper.attributes.getNamedItem('role').nodeValue).toEqual('combobox');
857-
expect(comboWrapper.attributes.getNamedItem('aria-haspopup').nodeValue).toEqual('listbox');
858-
expect(comboWrapper.attributes.getNamedItem('aria-expanded').nodeValue).toEqual('false');
859-
expect(comboWrapper.attributes.getNamedItem('aria-owns').nodeValue).toEqual(fixture.componentInstance.combo.dropdown.id);
849+
expect(comboWrapper.classList.contains(CSS_CLASS_COMBO)).toBeTruthy();
860850
expect(comboWrapper.childElementCount).toEqual(2);
861851

862-
const inputGroupElement = comboWrapper.children[0];
863-
expect(inputGroupElement.attributes.getNamedItem('ng-reflect-type').nodeValue).toEqual('box');
864-
expect(inputGroupElement.classList.contains(CSS_CLASS_INPUTGROUP)).toBeTruthy();
865-
expect(inputGroupElement.classList.contains('igx-input-group--box')).toBeTruthy();
866-
expect(inputGroupElement.classList.contains('igx-input-group--placeholder')).toBeTruthy();
867-
expect(inputGroupElement.childElementCount).toEqual(3);
868-
869-
const inputGroupWrapper = inputGroupElement.children[0];
870-
expect(inputGroupWrapper.classList.contains(CSS_CLASS_INPUTGROUP_WRAPPER)).toBeTruthy();
871-
expect(inputGroupWrapper.childElementCount).toEqual(1);
872-
873-
const inputGroupBundle = inputGroupWrapper.children[0];
874-
expect(inputGroupBundle.classList.contains(CSS_CLASS_INPUTGROUP_BUNDLE)).toBeTruthy();
875-
expect(inputGroupBundle.childElementCount).toEqual(2);
876-
877-
const mainInputGroupBundle = inputGroupBundle.children[0];
878-
expect(mainInputGroupBundle.classList.contains(CSS_CLASS_INPUTGROUP_MAINBUNDLE)).toBeTruthy();
879-
expect(mainInputGroupBundle.childElementCount).toEqual(1);
880-
881-
const inputElement = mainInputGroupBundle.children[0];
882-
expect(inputElement.classList.contains('igx-input-group__input')).toBeTruthy();
883-
expect(inputElement.attributes.getNamedItem('type').nodeValue).toEqual('text');
884-
expect(inputElement.attributes['readonly']).toBeDefined();
885-
886-
const dropDownButton = inputGroupBundle.children[1];
887-
expect(dropDownButton.classList.contains(CSS_CLASS_TOGGLEBUTTON)).toBeTruthy();
888-
expect(dropDownButton.childElementCount).toEqual(1);
889-
890-
const inputGroupBorder = inputGroupElement.children[1];
891-
expect(inputGroupBorder.classList.contains(CSS_CLASS_INPUTGROUP_BORDER)).toBeTruthy();
892-
expect(inputGroupBorder.childElementCount).toEqual(0);
893-
894852
const dropDownElement = comboWrapper.children[1];
895853
expect(dropDownElement.classList.contains(CSS_CLASS_COMBO_DROPDOWN)).toBeTruthy();
896854
expect(dropDownElement.classList.contains(CSS_CLASS_DROPDOWN)).toBeTruthy();
@@ -902,17 +860,44 @@ describe('igxCombo', () => {
902860
expect(dropDownList.classList.contains('igx-toggle--hidden')).toBeTruthy();
903861
expect(dropDownScrollList.childElementCount).toEqual(0);
904862
});
863+
it('should render aria attributes properly', fakeAsync(() => {
864+
expect(input.nativeElement.getAttribute('role')).toEqual('combobox');
865+
expect(input.nativeElement.getAttribute('aria-haspopup')).toEqual('listbox');
866+
expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('false');
867+
expect(input.nativeElement.getAttribute('aria-controls')).toEqual(combo.dropdown.listId);
868+
expect(input.nativeElement.getAttribute('aria-labelledby')).toEqual(combo.placeholder);
869+
870+
const dropdown = fixture.debugElement.query(By.css(`.${CSS_CLASS_COMBO_DROPDOWN}`));
871+
expect(dropdown.nativeElement.getAttribute('ng-reflect-labelled-by')).toEqual(combo.placeholder);
872+
873+
combo.open();
874+
tick();
875+
fixture.detectChanges();
876+
877+
const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT));
878+
expect(searchInput.nativeElement.getAttribute('role')).toEqual('searchbox');
879+
expect(searchInput.nativeElement.getAttribute('aria-label')).toEqual('search');
880+
expect(searchInput.nativeElement.getAttribute('aria-autocomplete')).toEqual('list');
881+
882+
const list = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`));
883+
expect(list.nativeElement.getAttribute('aria-multiselectable')).toEqual('true');
884+
expect(list.nativeElement.getAttribute('aria-activedescendant')).toEqual('');
885+
886+
UIInteractions.triggerEventHandlerKeyDown('ArrowDown', list);
887+
tick();
888+
fixture.detectChanges();
889+
expect(list.nativeElement.getAttribute('aria-activedescendant')).toEqual(combo.dropdown.focusedItem.id);
890+
}));
905891
it('should render aria-expanded attribute properly', fakeAsync(() => {
906-
const comboContainer = fixture.nativeElement.querySelector('.' + CSS_CLASS_COMBO);
907-
expect(comboContainer.getAttribute('aria-expanded')).toMatch('false');
892+
expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('false');
908893
combo.open();
909894
tick();
910895
fixture.detectChanges();
911-
expect(comboContainer.getAttribute('aria-expanded')).toMatch('true');
896+
expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('true');
912897
combo.close();
913898
tick();
914899
fixture.detectChanges();
915-
expect(comboContainer.getAttribute('aria-expanded')).toMatch('false');
900+
expect(input.nativeElement.getAttribute('aria-expanded')).toMatch('false');
916901
}));
917902
it('should render placeholder values for inputs properly', () => {
918903
combo.toggle();

projects/igniteui-angular/src/lib/drop-down/drop-down.component.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
(appended)="onToggleContentAppended($event)"
44
(opening)="onToggleOpening($event)" (opened)="onToggleOpened()"
55
(closing)="onToggleClosing($event)" (closed)="onToggleClosed()">
6-
<div class="igx-drop-down__list-scroll" #scrollContainer [attr.id]="this.listId" role="listbox" [attr.aria-label]="this.listId"
6+
<div class="igx-drop-down__list-scroll" #scrollContainer [attr.id]="this.listId" role="listbox" [attr.aria-labelledby]="this.labelledBy"
77
[style.height]="height"
88
[style.maxHeight]="maxHeight">
99
<ng-container *ngIf="!collapsed">

projects/igniteui-angular/src/lib/drop-down/drop-down.component.ts

+9
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,15 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID
122122
@Input()
123123
public allowItemsFocus = false;
124124

125+
/**
126+
* An @Input property that set aria-labelledby attribute
127+
* ```html
128+
* <igx-drop-down [labelledby]="labelId"></igx-drop-down>
129+
* ```
130+
*/
131+
@Input()
132+
public labelledBy: string;
133+
125134
@ContentChild(IgxForOfDirective, { read: IgxForOfDirective })
126135
protected virtDir: IgxForOfDirective<any>;
127136

0 commit comments

Comments
 (0)