Skip to content

Commit 2b2a651

Browse files
authored
Merge pull request #13666 from IgniteUI/mdragnev/esf-16.1.x
Add support for navigation in the ESF search list
2 parents 996663a + 883ac0a commit 2b2a651

File tree

8 files changed

+255
-12
lines changed

8 files changed

+255
-12
lines changed

projects/igniteui-angular/src/lib/core/styles/components/list/_list-component.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
@extend %igx-list-item-base !optional;
2424
}
2525

26+
// css class `igx-list__item-base--active
27+
@include e(item-base, $m: active) {
28+
@extend %igx-list-item-base--active !optional;
29+
}
30+
2631
// css class 'igx-list__item-right' applied to the panning container shown when the list item is panned left
2732
@include e(item-right) {
2833
@extend %igx-list-item-pan !optional;

projects/igniteui-angular/src/lib/core/styles/components/list/_list-theme.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,10 @@
292292
overflow-x: hidden;
293293
z-index: 0;
294294
border-radius: var-get($theme, 'border-radius');
295+
296+
&:focus-visible {
297+
outline-style: none;
298+
}
295299
}
296300

297301
%igx-list--empty {
@@ -367,6 +371,12 @@
367371
}
368372
}
369373

374+
%igx-list-item-base--active {
375+
%igx-list-item-content {
376+
@extend %igx-list-item-content--active;
377+
}
378+
}
379+
370380
%igx-list-item-pan {
371381
position: absolute;
372382
visibility: hidden;

projects/igniteui-angular/src/lib/grids/filtering/excel-style/common.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ export class ExpressionUI {
2727
public isVisible = true;
2828
}
2929

30+
/**
31+
* @hidden @internal
32+
*/
33+
export class ActiveElement {
34+
public index: number;
35+
public id: string;
36+
public checked: boolean;
37+
}
38+
3039
export function generateExpressionsList(expressions: IFilteringExpressionsTree | IFilteringExpression,
3140
operator: FilteringLogic,
3241
expressionsUIs: ExpressionUI[]): void {

projects/igniteui-angular/src/lib/grids/filtering/excel-style/excel-style-search.component.html

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
</igx-icon>
2121
</igx-input-group>
2222

23-
<igx-list #list [displayDensity]="esf.displayDensity" [isLoading]="isLoading" *ngIf="!isHierarchical()">
23+
<igx-list #list role="listbox" [displayDensity]="esf.displayDensity" [isLoading]="isLoading" *ngIf="!isHierarchical()" (keydown)="handleKeyDown($event)" tabindex="0"
24+
[attr.aria-activedescendant]="this.activeDescendant" (focus)="onFocus()" (focusout)="onFocusOut()">
2425
<div style="overflow: hidden; position: relative;">
25-
<igx-list-item
26-
*igxFor="let item of displayedListData scrollOrientation : 'vertical'; containerSize: containerSize; itemSize: itemSize">
26+
<igx-list-item [class.igx-list__item-base--active]="focusedItem?.id === this.getItemId(i)" [attr.id]="getItemId(i)" role="option"
27+
*igxFor="let item of displayedListData;index as i; scrollOrientation : 'vertical'; containerSize: containerSize; itemSize: itemSize">
2728
<igx-checkbox
2829
[value]="item"
2930
[tabindex]="-1"
@@ -136,7 +137,7 @@
136137

137138
<footer class="igx-excel-filter__menu-footer">
138139
<div class="igx-excel-filter__cancel">
139-
<button type="button"
140+
<button type="button" #cancelButton
140141
igxButton="flat"
141142
[displayDensity]="esf.displayDensity"
142143
(click)="esf.cancel()">

projects/igniteui-angular/src/lib/grids/filtering/excel-style/excel-style-search.component.ts

Lines changed: 160 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import {
66
TemplateRef,
77
Directive,
88
OnDestroy,
9-
HostBinding
9+
HostBinding,
10+
Input,
11+
ViewChildren,
12+
QueryList
1013
} from '@angular/core';
1114
import { IgxInputDirective } from '../../../directives/input/input.directive';
1215
import { DisplayDensity } from '../../../core/density';
@@ -23,7 +26,7 @@ import { IChangeCheckboxEventArgs, IgxCheckboxComponent } from '../../../checkbo
2326
import { takeUntil } from 'rxjs/operators';
2427
import { cloneHierarchicalArray, PlatformUtil } from '../../../core/utils';
2528
import { BaseFilteringComponent } from './base-filtering.component';
26-
import { ExpressionUI, FilterListItem } from './common';
29+
import { ActiveElement, ExpressionUI, FilterListItem } from './common';
2730
import { IgxButtonDirective } from '../../../directives/button/button.directive';
2831
import { IgxCircularProgressBarComponent } from '../../../progressbar/progressbar.component';
2932
import { IgxTreeNodeComponent } from '../../../tree/tree-node/tree-node.component';
@@ -38,6 +41,7 @@ import { IgxPrefixDirective } from '../../../directives/prefix/prefix.directive'
3841
import { IgxIconComponent } from '../../../icon/icon.component';
3942
import { IgxInputGroupComponent } from '../../../input-group/input-group.component';
4043
import { ITreeNodeSelectionEvent } from '../../../tree/common';
44+
import { Navigate } from '../../../drop-down/drop-down.common';
4145

4246
@Directive({
4347
selector: '[igxExcelStyleLoading]',
@@ -51,6 +55,7 @@ export class IgxExcelStyleLoadingValuesTemplateDirective {
5155
constructor(public template: TemplateRef<undefined>) { }
5256
}
5357

58+
let NEXT_ID = 0;
5459
/**
5560
* A component used for presenting Excel style search UI.
5661
*/
@@ -75,6 +80,9 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
7580
@ViewChild('input', { read: IgxInputDirective, static: true })
7681
public searchInput: IgxInputDirective;
7782

83+
@ViewChild('cancelButton', {read: IgxButtonDirective, static: true })
84+
protected cancelButton: IgxButtonDirective;
85+
7886
/**
7987
* @hidden @internal
8088
*/
@@ -102,7 +110,7 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
102110
/**
103111
* @hidden @internal
104112
*/
105-
@ViewChild(IgxForOfDirective, { static: true })
113+
@ViewChild(IgxForOfDirective)
106114
protected virtDir: IgxForOfDirective<any>;
107115

108116
/**
@@ -111,6 +119,9 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
111119
@ViewChild('defaultExcelStyleLoadingValuesTemplate', { read: TemplateRef })
112120
protected defaultExcelStyleLoadingValuesTemplate: TemplateRef<any>;
113121

122+
@ViewChildren(IgxCheckboxComponent)
123+
protected checkboxes: QueryList<IgxCheckboxComponent>;
124+
114125
/**
115126
* @hidden @internal
116127
*/
@@ -196,10 +207,14 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
196207
}
197208
}
198209

210+
protected activeDescendant = '';
211+
212+
private _id = `igx-excel-style-search-${NEXT_ID++}`;
199213
private _isLoading;
200214
private _addToCurrentFilterItem: FilterListItem;
201215
private _selectAllItem: FilterListItem;
202216
private _hierarchicalSelectedItems: FilterListItem[];
217+
private _focusedItem: ActiveElement = null;
203218
private destroy$ = new Subject<boolean>();
204219

205220
constructor(public cdr: ChangeDetectorRef, public esf: BaseFilteringComponent, protected platform: PlatformUtil) {
@@ -303,7 +318,6 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
303318
selectAllBtn.indeterminate = true;
304319
}
305320
}
306-
eventArgs.checkbox.nativeCheckbox.nativeElement.blur();
307321
}
308322

309323
/**
@@ -368,6 +382,31 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
368382
return 0;
369383
}
370384

385+
@HostBinding('attr.id')
386+
@Input()
387+
protected get id(): string {
388+
return this._id;
389+
}
390+
protected set id(value: string) {
391+
this._id = value;
392+
}
393+
394+
protected getItemId(index: number): string {
395+
return `${this.id}-item-${index}`;
396+
}
397+
398+
protected setActiveDescendant() : void {
399+
this.activeDescendant = this.focusedItem?.id || '';
400+
}
401+
402+
protected get focusedItem(): ActiveElement {
403+
return this._focusedItem;
404+
}
405+
406+
protected set focusedItem(val: ActiveElement) {
407+
this._focusedItem = val;
408+
}
409+
371410
/**
372411
* @hidden @internal
373412
*/
@@ -576,6 +615,57 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
576615
this.esf.closeDropdown();
577616
}
578617

618+
protected handleKeyDown(event: KeyboardEvent) {
619+
if (event) {
620+
const key = event.key.toLowerCase();
621+
const navKeys = ['space', 'spacebar', ' ',
622+
'arrowup', 'up', 'arrowdown', 'down', 'home', 'end'];
623+
if (navKeys.indexOf(key) === -1) { // If key has appropriate function in DD
624+
return;
625+
}
626+
event.preventDefault();
627+
event.stopPropagation();
628+
switch (key) {
629+
case 'arrowup':
630+
case 'up':
631+
this.onArrowUpKeyDown();
632+
break;
633+
case 'arrowdown':
634+
case 'down':
635+
this.onArrowDownKeyDown();
636+
break;
637+
case 'home':
638+
this.onHomeKeyDown();
639+
break;
640+
case 'end':
641+
this.onEndKeyDown();
642+
break;
643+
case 'space':
644+
case 'spacebar':
645+
case ' ':
646+
this.onActionKeyDown();
647+
break;
648+
default:
649+
return;
650+
}
651+
}
652+
}
653+
654+
protected onFocus() {
655+
const firstIndexInView = this.virtDir.state.startIndex;
656+
this.focusedItem = {
657+
id: this.getItemId(firstIndexInView),
658+
index: firstIndexInView,
659+
checked: this.virtDir.igxForOf[firstIndexInView].isSelected
660+
};
661+
this.setActiveDescendant();
662+
}
663+
664+
protected onFocusOut() {
665+
this.focusedItem = null;
666+
this.setActiveDescendant();
667+
}
668+
579669
/**
580670
* @hidden @internal
581671
*/
@@ -676,4 +766,70 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
676766
this.searchValue = this.searchInput.value;
677767
}
678768
}
769+
770+
private onArrowUpKeyDown() {
771+
if (this.focusedItem && this.focusedItem.index === 0 && this.virtDir.state.startIndex === 0) {
772+
this.searchInput.focus();
773+
this.onFocusOut();
774+
} else {
775+
this.navigateItem(this.focusedItem ? this.focusedItem.index - 1 : 0);
776+
}
777+
this.setActiveDescendant();
778+
}
779+
780+
private onArrowDownKeyDown() {
781+
const lastIndex = this.virtDir.igxForOf.length - 1;
782+
if (this.focusedItem && this.focusedItem.index === lastIndex) {
783+
this.cancelButton.nativeElement.focus();
784+
this.onFocusOut();
785+
} else {
786+
this.navigateItem(this.focusedItem ? this.focusedItem.index + 1 : 0);
787+
}
788+
this.setActiveDescendant();
789+
}
790+
791+
private onHomeKeyDown() {
792+
this.navigateItem(0);
793+
this.setActiveDescendant();
794+
}
795+
796+
private onEndKeyDown() {
797+
this.navigateItem(this.virtDir.igxForOf.length - 1);
798+
this.setActiveDescendant();
799+
}
800+
801+
private onActionKeyDown() {
802+
const dataItem = this.displayedListData[this.focusedItem.index];
803+
const args: IChangeCheckboxEventArgs = {
804+
checked: !dataItem.isSelected,
805+
checkbox: this.checkboxes.find(x => x.value === dataItem)
806+
}
807+
this.onCheckboxChange(args);
808+
}
809+
810+
private navigateItem(index: number) {
811+
if (index === -1 || index >= this.virtDir.igxForOf.length) {
812+
return;
813+
}
814+
const direction = index > (this.focusedItem ? this.focusedItem.index : -1) ? Navigate.Down : Navigate.Up;
815+
const scrollRequired = this.isIndexOutOfBounds(index, direction);
816+
this.focusedItem = {
817+
id: this.getItemId(index),
818+
index: index,
819+
checked: this.virtDir.igxForOf[index].isSelected
820+
};
821+
if (scrollRequired) {
822+
this.virtDir.scrollTo(index);
823+
}
824+
}
825+
826+
private isIndexOutOfBounds(index: number, direction: Navigate) {
827+
const virtState = this.virtDir.state;
828+
const currentPosition = this.virtDir.getScroll().scrollTop;
829+
const itemPosition = this.virtDir.getScrollForIndex(index, direction === Navigate.Down);
830+
const indexOutOfChunk = index < virtState.startIndex || index > virtState.chunkSize + virtState.startIndex;
831+
const scrollNeeded = direction === Navigate.Down ? currentPosition < itemPosition : currentPosition > itemPosition;
832+
const subRequired = indexOutOfChunk || scrollNeeded;
833+
return subRequired;
834+
}
679835
}

projects/igniteui-angular/src/lib/grids/grid/grid-filtering-ui.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5432,6 +5432,54 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => {
54325432
ControlsFunction.verifyButtonIsDisabled(applyButton);
54335433
}));
54345434

5435+
it('Should be able to navigate inside the search list items', fakeAsync(() => {
5436+
GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'Downloads');
5437+
const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix);
5438+
const list = searchComponent.querySelector('igx-list');
5439+
list.dispatchEvent(new Event('focus'));
5440+
tick(DEBOUNCETIME);
5441+
fix.detectChanges();
5442+
const listItems = list.querySelectorAll('igx-list-item');
5443+
5444+
// we expect only the first list item to be active when the list is focused
5445+
expect(listItems[0].classList.contains("igx-list__item-base--active")).toBeTrue();
5446+
expect(listItems[1].classList.contains("igx-list__item-base--active")).toBeFalse();
5447+
5448+
// on arrow down the second item should be active
5449+
UIInteractions.triggerKeyDownEvtUponElem('arrowdown', list, true);
5450+
fix.detectChanges();
5451+
expect(listItems[0].classList.contains("igx-list__item-base--active")).toBeFalse();
5452+
expect(listItems[1].classList.contains("igx-list__item-base--active")).toBeTrue();
5453+
5454+
// on arrow up the first item should be active again
5455+
UIInteractions.triggerKeyDownEvtUponElem('arrowup', list, true);
5456+
fix.detectChanges();
5457+
expect(listItems[0].classList.contains("igx-list__item-base--active")).toBeTrue();
5458+
expect(listItems[1].classList.contains("igx-list__item-base--active")).toBeFalse();
5459+
5460+
// on home the first item should be active
5461+
UIInteractions.triggerKeyDownEvtUponElem('arrowdown', list, true);
5462+
fix.detectChanges();
5463+
expect(listItems[1].classList.contains("igx-list__item-base--active")).toBeTrue();
5464+
UIInteractions.triggerKeyDownEvtUponElem('home', list, true);
5465+
fix.detectChanges();
5466+
expect(listItems[0].classList.contains("igx-list__item-base--active")).toBeTrue();
5467+
5468+
// on space key on the first item (select all) all the checkbox should deselect
5469+
let checkboxes = list.querySelectorAll('igx-checkbox');
5470+
let checkboxesStatus = Array.from(checkboxes).map((checkbox: Element) => checkbox.querySelector('input').checked);
5471+
checkboxesStatus.forEach(status => {
5472+
expect(status).toBeTrue();
5473+
});
5474+
UIInteractions.triggerKeyDownEvtUponElem('space', list, true);
5475+
fix.detectChanges();
5476+
checkboxes = list.querySelectorAll('igx-checkbox');
5477+
checkboxesStatus = Array.from(checkboxes).map((checkbox: Element) => checkbox.querySelector('input').checked);
5478+
checkboxesStatus.forEach(status => {
5479+
expect(status).toBeFalse();
5480+
});
5481+
}));
5482+
54355483
it('Should add list items to current filtered items when "Add to current filter selection" is selected.', fakeAsync(() => {
54365484
const totalListItems = [];
54375485

0 commit comments

Comments
 (0)