Skip to content

Commit dca95e1

Browse files
authored
Merge pull request #13635 from IgniteUI/mdragnev/implement-13519
fix(ESF): Add support for navigation in the ESF search list
2 parents d94a2ca + 638d731 commit dca95e1

File tree

8 files changed

+252
-12
lines changed

8 files changed

+252
-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: 157 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
TemplateRef,
77
Directive,
88
OnDestroy,
9-
HostBinding
9+
HostBinding,
10+
Input
1011
} from '@angular/core';
1112
import { IgxInputDirective } from '../../../directives/input/input.directive';
1213
import { DisplayDensity } from '../../../core/density';
@@ -23,7 +24,7 @@ import { IChangeCheckboxEventArgs, IgxCheckboxComponent } from '../../../checkbo
2324
import { takeUntil } from 'rxjs/operators';
2425
import { cloneHierarchicalArray, PlatformUtil } from '../../../core/utils';
2526
import { BaseFilteringComponent } from './base-filtering.component';
26-
import { ExpressionUI, FilterListItem } from './common';
27+
import { ActiveElement, ExpressionUI, FilterListItem } from './common';
2728
import { IgxButtonDirective } from '../../../directives/button/button.directive';
2829
import { IgxCircularProgressBarComponent } from '../../../progressbar/progressbar.component';
2930
import { IgxTreeNodeComponent } from '../../../tree/tree-node/tree-node.component';
@@ -38,6 +39,7 @@ import { IgxPrefixDirective } from '../../../directives/prefix/prefix.directive'
3839
import { IgxIconComponent } from '../../../icon/icon.component';
3940
import { IgxInputGroupComponent } from '../../../input-group/input-group.component';
4041
import { ITreeNodeSelectionEvent } from '../../../tree/common';
42+
import { Navigate } from '../../../drop-down/drop-down.common';
4143

4244
@Directive({
4345
selector: '[igxExcelStyleLoading]',
@@ -51,6 +53,7 @@ export class IgxExcelStyleLoadingValuesTemplateDirective {
5153
constructor(public template: TemplateRef<undefined>) { }
5254
}
5355

56+
let NEXT_ID = 0;
5457
/**
5558
* A component used for presenting Excel style search UI.
5659
*/
@@ -75,6 +78,9 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
7578
@ViewChild('input', { read: IgxInputDirective, static: true })
7679
public searchInput: IgxInputDirective;
7780

81+
@ViewChild('cancelButton', {read: IgxButtonDirective, static: true })
82+
protected cancelButton: IgxButtonDirective;
83+
7884
/**
7985
* @hidden @internal
8086
*/
@@ -102,7 +108,7 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
102108
/**
103109
* @hidden @internal
104110
*/
105-
@ViewChild(IgxForOfDirective, { static: true })
111+
@ViewChild(IgxForOfDirective)
106112
protected virtDir: IgxForOfDirective<any>;
107113

108114
/**
@@ -196,10 +202,14 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
196202
}
197203
}
198204

205+
protected activeDescendant = '';
206+
207+
private _id = `igx-excel-style-search-${NEXT_ID++}`;
199208
private _isLoading;
200209
private _addToCurrentFilterItem: FilterListItem;
201210
private _selectAllItem: FilterListItem;
202211
private _hierarchicalSelectedItems: FilterListItem[];
212+
private _focusedItem: ActiveElement = null;
203213
private destroy$ = new Subject<boolean>();
204214

205215
constructor(public cdr: ChangeDetectorRef, public esf: BaseFilteringComponent, protected platform: PlatformUtil) {
@@ -303,7 +313,6 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
303313
selectAllBtn.indeterminate = true;
304314
}
305315
}
306-
eventArgs.owner.nativeInput.nativeElement.blur();
307316
}
308317

309318
/**
@@ -368,6 +377,31 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
368377
return 0;
369378
}
370379

380+
@HostBinding('attr.id')
381+
@Input()
382+
protected get id(): string {
383+
return this._id;
384+
}
385+
protected set id(value: string) {
386+
this._id = value;
387+
}
388+
389+
protected getItemId(index: number): string {
390+
return `${this.id}-item-${index}`;
391+
}
392+
393+
protected setActiveDescendant() : void {
394+
this.activeDescendant = this.focusedItem?.id || '';
395+
}
396+
397+
protected get focusedItem(): ActiveElement {
398+
return this._focusedItem;
399+
}
400+
401+
protected set focusedItem(val: ActiveElement) {
402+
this._focusedItem = val;
403+
}
404+
371405
/**
372406
* @hidden @internal
373407
*/
@@ -576,6 +610,57 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
576610
this.esf.closeDropdown();
577611
}
578612

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

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
@@ -5439,6 +5439,54 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => {
54395439
ControlsFunction.verifyButtonIsDisabled(applyButton);
54405440
}));
54415441

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

0 commit comments

Comments
 (0)