Skip to content

Commit aff0d12

Browse files
authored
Merge pull request #13669 from IgniteUI/mdragnev/esf-15.1.x
Add support for navigation in the ESF search list [15.1.x]
2 parents e664473 + 410d093 commit aff0d12

File tree

8 files changed

+256
-12
lines changed

8 files changed

+256
-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
@@ -280,6 +280,10 @@
280280
overflow-x: hidden;
281281
z-index: 0;
282282
border-radius: var-get($theme, 'border-radius');
283+
284+
&:focus-visible {
285+
outline-style: none;
286+
}
283287
}
284288

285289
%igx-list--empty {
@@ -361,6 +365,12 @@
361365
}
362366
}
363367

368+
%igx-list-item-base--active {
369+
%igx-list-item-content {
370+
@extend %igx-list-item-content--active;
371+
}
372+
}
373+
364374
%igx-list-item-pan {
365375
position: absolute;
366376
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: 161 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+
QueryList,
12+
ViewChildren
1013
} from '@angular/core';
1114
import { IgxInputDirective } from '../../../directives/input/input.directive';
1215
import { DisplayDensity } from '../../../core/density';
@@ -24,8 +27,10 @@ import { IChangeCheckboxEventArgs, IgxCheckboxComponent } from '../../../checkbo
2427
import { takeUntil } from 'rxjs/operators';
2528
import { cloneHierarchicalArray, PlatformUtil } from '../../../core/utils';
2629
import { BaseFilteringComponent } from './base-filtering.component';
27-
import { ExpressionUI, FilterListItem } from './common';
30+
import { ActiveElement, ExpressionUI, FilterListItem } from './common';
2831
import { IgxTreeComponent, ITreeNodeSelectionEvent } from '../../../tree/public_api';
32+
import { Navigate } from '../../../drop-down/drop-down.common';
33+
import { IgxButtonDirective } from '../../../directives/button/button.directive';
2934

3035
@Directive({
3136
selector: '[igxExcelStyleLoading]'
@@ -38,6 +43,7 @@ export class IgxExcelStyleLoadingValuesTemplateDirective {
3843
constructor(public template: TemplateRef<undefined>) { }
3944
}
4045

46+
let NEXT_ID = 0;
4147
/**
4248
* A component used for presenting Excel style search UI.
4349
*/
@@ -60,6 +66,9 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
6066
@ViewChild('input', { read: IgxInputDirective, static: true })
6167
public searchInput: IgxInputDirective;
6268

69+
@ViewChild('cancelButton', {read: IgxButtonDirective, static: true })
70+
protected cancelButton: IgxButtonDirective;
71+
6372
/**
6473
* @hidden @internal
6574
*/
@@ -87,7 +96,7 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
8796
/**
8897
* @hidden @internal
8998
*/
90-
@ViewChild(IgxForOfDirective, { static: true })
99+
@ViewChild(IgxForOfDirective)
91100
protected virtDir: IgxForOfDirective<any>;
92101

93102
/**
@@ -96,6 +105,9 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
96105
@ViewChild('defaultExcelStyleLoadingValuesTemplate', { read: TemplateRef })
97106
protected defaultExcelStyleLoadingValuesTemplate: TemplateRef<any>;
98107

108+
@ViewChildren(IgxCheckboxComponent)
109+
protected checkboxes: QueryList<IgxCheckboxComponent>;
110+
99111
/**
100112
* @hidden @internal
101113
*/
@@ -181,10 +193,14 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
181193
}
182194
}
183195

196+
protected activeDescendant = '';
197+
198+
private _id = `igx-excel-style-search-${NEXT_ID++}`;
184199
private _isLoading;
185200
private _addToCurrentFilterItem: FilterListItem;
186201
private _selectAllItem: FilterListItem;
187202
private _hierarchicalSelectedItems: FilterListItem[];
203+
private _focusedItem: ActiveElement = null;
188204
private destroy$ = new Subject<boolean>();
189205

190206
constructor(public cdr: ChangeDetectorRef, public esf: BaseFilteringComponent, protected platform: PlatformUtil) {
@@ -288,7 +304,6 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
288304
selectAllBtn.indeterminate = true;
289305
}
290306
}
291-
eventArgs.checkbox.nativeCheckbox.nativeElement.blur();
292307
}
293308

294309
/**
@@ -353,6 +368,31 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
353368
return 0;
354369
}
355370

371+
@HostBinding('attr.id')
372+
@Input()
373+
protected get id(): string {
374+
return this._id;
375+
}
376+
protected set id(value: string) {
377+
this._id = value;
378+
}
379+
380+
protected getItemId(index: number): string {
381+
return `${this.id}-item-${index}`;
382+
}
383+
384+
protected setActiveDescendant() : void {
385+
this.activeDescendant = this.focusedItem?.id || '';
386+
}
387+
388+
protected get focusedItem(): ActiveElement {
389+
return this._focusedItem;
390+
}
391+
392+
protected set focusedItem(val: ActiveElement) {
393+
this._focusedItem = val;
394+
}
395+
356396
/**
357397
* @hidden @internal
358398
*/
@@ -561,6 +601,57 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
561601
this.esf.closeDropdown();
562602
}
563603

604+
protected handleKeyDown(event: KeyboardEvent) {
605+
if (event) {
606+
const key = event.key.toLowerCase();
607+
const navKeys = ['space', 'spacebar', ' ',
608+
'arrowup', 'up', 'arrowdown', 'down', 'home', 'end'];
609+
if (navKeys.indexOf(key) === -1) { // If key has appropriate function in DD
610+
return;
611+
}
612+
event.preventDefault();
613+
event.stopPropagation();
614+
switch (key) {
615+
case 'arrowup':
616+
case 'up':
617+
this.onArrowUpKeyDown();
618+
break;
619+
case 'arrowdown':
620+
case 'down':
621+
this.onArrowDownKeyDown();
622+
break;
623+
case 'home':
624+
this.onHomeKeyDown();
625+
break;
626+
case 'end':
627+
this.onEndKeyDown();
628+
break;
629+
case 'space':
630+
case 'spacebar':
631+
case ' ':
632+
this.onActionKeyDown();
633+
break;
634+
default:
635+
return;
636+
}
637+
}
638+
}
639+
640+
protected onFocus() {
641+
const firstIndexInView = this.virtDir.state.startIndex;
642+
this.focusedItem = {
643+
id: this.getItemId(firstIndexInView),
644+
index: firstIndexInView,
645+
checked: this.virtDir.igxForOf[firstIndexInView].isSelected
646+
};
647+
this.setActiveDescendant();
648+
}
649+
650+
protected onFocusOut() {
651+
this.focusedItem = null;
652+
this.setActiveDescendant();
653+
}
654+
564655
/**
565656
* @hidden @internal
566657
*/
@@ -661,4 +752,70 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy {
661752
this.searchValue = this.searchInput.value;
662753
}
663754
}
755+
756+
private onArrowUpKeyDown() {
757+
if (this.focusedItem && this.focusedItem.index === 0 && this.virtDir.state.startIndex === 0) {
758+
this.searchInput.focus();
759+
this.onFocusOut();
760+
} else {
761+
this.navigateItem(this.focusedItem ? this.focusedItem.index - 1 : 0);
762+
}
763+
this.setActiveDescendant();
764+
}
765+
766+
private onArrowDownKeyDown() {
767+
const lastIndex = this.virtDir.igxForOf.length - 1;
768+
if (this.focusedItem && this.focusedItem.index === lastIndex) {
769+
this.cancelButton.nativeElement.focus();
770+
this.onFocusOut();
771+
} else {
772+
this.navigateItem(this.focusedItem ? this.focusedItem.index + 1 : 0);
773+
}
774+
this.setActiveDescendant();
775+
}
776+
777+
private onHomeKeyDown() {
778+
this.navigateItem(0);
779+
this.setActiveDescendant();
780+
}
781+
782+
private onEndKeyDown() {
783+
this.navigateItem(this.virtDir.igxForOf.length - 1);
784+
this.setActiveDescendant();
785+
}
786+
787+
private onActionKeyDown() {
788+
const dataItem = this.displayedListData[this.focusedItem.index];
789+
const args: IChangeCheckboxEventArgs = {
790+
checked: !dataItem.isSelected,
791+
checkbox: this.checkboxes.find(x => x.value === dataItem)
792+
}
793+
this.onCheckboxChange(args);
794+
}
795+
796+
private navigateItem(index: number) {
797+
if (index === -1 || index >= this.virtDir.igxForOf.length) {
798+
return;
799+
}
800+
const direction = index > (this.focusedItem ? this.focusedItem.index : -1) ? Navigate.Down : Navigate.Up;
801+
const scrollRequired = this.isIndexOutOfBounds(index, direction);
802+
this.focusedItem = {
803+
id: this.getItemId(index),
804+
index: index,
805+
checked: this.virtDir.igxForOf[index].isSelected
806+
};
807+
if (scrollRequired) {
808+
this.virtDir.scrollTo(index);
809+
}
810+
}
811+
812+
private isIndexOutOfBounds(index: number, direction: Navigate) {
813+
const virtState = this.virtDir.state;
814+
const currentPosition = this.virtDir.getScroll().scrollTop;
815+
const itemPosition = this.virtDir.getScrollForIndex(index, direction === Navigate.Down);
816+
const indexOutOfChunk = index < virtState.startIndex || index > virtState.chunkSize + virtState.startIndex;
817+
const scrollNeeded = direction === Navigate.Down ? currentPosition < itemPosition : currentPosition > itemPosition;
818+
const subRequired = indexOutOfChunk || scrollNeeded;
819+
return subRequired;
820+
}
664821
}

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

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

0 commit comments

Comments
 (0)