Skip to content

Commit

Permalink
feat(filter-field): Added keyboard functionality to multiselect dropd…
Browse files Browse the repository at this point in the history
…own.
  • Loading branch information
manulopez2dynatrace authored and Sherif-Elhefnawy committed Aug 29, 2022
1 parent 6e7b332 commit 939fb17
Show file tree
Hide file tree
Showing 7 changed files with 274 additions and 36 deletions.
Expand Up @@ -353,7 +353,7 @@ test('should choose a multiselect node with the keyboard and submit the correct
.expect(tags.length)
.eql(1)
.expect(tags[0])
.eql('SeasoningKetchup');
.eql('SeasoningNone');
});

test('should not apply an empty multiselect node with the keyboard', async (testController: TestController) => {
Expand Down
Expand Up @@ -78,6 +78,15 @@ export class DtFilterFieldMultiSelectTrigger<T>
super.elementDisabled = value;
}

/**
* It will allow to check if the key manager is already focused on the first active item on multi select panel
*/
isKeyManagerFocusAlreadyOnTop = false;
/**
* It will allow to check if the key manager is already focused on the last active item on multi select panel
*/
isKeyManagerFocusAlreadyOnBottom = false;

constructor(
protected _elementRef: ElementRef,
protected _overlay: Overlay,
Expand Down Expand Up @@ -106,10 +115,105 @@ export class DtFilterFieldMultiSelectTrigger<T>
/** Opens the filter-field multiSelect panel. */
openPanel(): void {
if (!this.element._isOpen) {
// As now there is no autofocus anymore, the key manager and its boundaries must be reset after opening the panel
this.resetActiveItemAndKeyManagerLimitsOnPanel();
super.openPanel();
}
}

/** Closes the filter-field multiSelect panel. */
closePanel(shouldEmit: boolean = true): void {
if (this.element._isOpen) {
// As now there is no autofocus anymore, the key manager and its boundaries must be reset after closing the panel
this.resetActiveItemAndKeyManagerLimitsOnPanel();
super.closePanel(shouldEmit);
}
}

/** Reset the focus of keymanager and limits on panel */
resetActiveItemAndKeyManagerLimitsOnPanel(doScroll: boolean = true): void {
this._resetActiveItem();
this._resetBoundariesOnKeyManager(doScroll);
}

/** Before, navigation was done via wrapping the keymanager, now is done checking current active indexes and key event */
handleCustomArrowKeyNavigation(keyCode: number): void {
if (keyCode === DOWN_ARROW || keyCode === UP_ARROW) {
// Identify valid indexes of active items as key manager cant provide list
const activeItemIndexes: number[] = [];
this.element._options.toArray().forEach((element, index) => {
if (!element.disabled) {
activeItemIndexes.push(index);
}
});

// if is the first active element
if (
keyCode == UP_ARROW &&
this.element._keyManager.activeItemIndex == activeItemIndexes[0] &&
activeItemIndexes.length > 1
) {
// if has limit set, go to input focus
if (this.isKeyManagerFocusAlreadyOnTop) {
this.resetActiveItemAndKeyManagerLimitsOnPanel();
// set limit on top
} else {
this.isKeyManagerFocusAlreadyOnTop = true;
this.isKeyManagerFocusAlreadyOnBottom =
activeItemIndexes.length === 1;
}
// from input focus, go to the last active item
} else if (
keyCode == UP_ARROW &&
this.element._keyManager.activeItemIndex == -1 &&
activeItemIndexes.length > 0
) {
this.element._keyManager.setLastItemActive();
this.isKeyManagerFocusAlreadyOnTop = activeItemIndexes.length === 1;
this.isKeyManagerFocusAlreadyOnBottom = true;
// update limit on first active item
} else if (
keyCode == DOWN_ARROW &&
this.element._keyManager.activeItemIndex == activeItemIndexes[0] &&
activeItemIndexes.length > 1
) {
this.isKeyManagerFocusAlreadyOnTop = true;
this.isKeyManagerFocusAlreadyOnBottom = false;
// if is the last active element
} else if (
keyCode == DOWN_ARROW &&
this.element._keyManager.activeItemIndex ==
activeItemIndexes[activeItemIndexes.length - 1]
) {
// if has limit set, go to input focus
if (this.isKeyManagerFocusAlreadyOnBottom) {
this.resetActiveItemAndKeyManagerLimitsOnPanel();
// set limit on bottom
} else {
this.isKeyManagerFocusAlreadyOnTop = activeItemIndexes.length === 1;
this.isKeyManagerFocusAlreadyOnBottom = true;
}
// if unique option, reset to input focus
} else if (activeItemIndexes.length === 1) {
this.resetActiveItemAndKeyManagerLimitsOnPanel();
// in any other case, reset
} else {
this.isKeyManagerFocusAlreadyOnTop = false;
this.isKeyManagerFocusAlreadyOnBottom = false;
}
// do scroll, specially when jumping from panel to input and viceversa
if (
this.isKeyManagerFocusAlreadyOnTop ||
this.isKeyManagerFocusAlreadyOnBottom
) {
// When selecting the first active or last active element from list, scroll will take place
this.element._keyManager.activeItem
?._getHostElement()
?.scrollIntoView();
}
}
}

/** @internal Handler for the users key down events. */
_handleKeydown(event: KeyboardEvent): void {
const keyCode = _readKeyCode(event);
Expand All @@ -124,7 +228,7 @@ export class DtFilterFieldMultiSelectTrigger<T>

if (!this.element._applyDisabled && keyCode === ENTER && this.panelOpen) {
this.element._handleSubmit(event);
this._resetActiveItem();
this.resetActiveItemAndKeyManagerLimitsOnPanel();

event.preventDefault();
} else if (this.element && this.element._keyManager) {
Expand All @@ -147,15 +251,16 @@ export class DtFilterFieldMultiSelectTrigger<T>
this.element._keyManager.activeItem !== prevActiveItem
) {
this._scrollToOption();
this.element._keyManager.activeItem
?._getHostElement()
?.scrollIntoView();
}
}
}

/** Resets the active item to -1 so arrow events will activate the correct options, or to 0 if the consumer opted into it. */
/** Resets the active item to -1 so arrow events will activate the correct options. */
protected _resetActiveItem(): void {
this.element._keyManager.setActiveItem(
this.element.autoActiveFirstOption ? 0 : -1,
);
this.element._keyManager.setActiveItem(-1);
}

/** The currently active option, coerced to DtOption type. */
Expand All @@ -166,6 +271,17 @@ export class DtFilterFieldMultiSelectTrigger<T>
return null;
}

/**
* After doing use of key navigation, it will reset boundary controls on multi select panel
* @param doScroll if true, does scroll into view for the current focused element, on the key manager
*/
private _resetBoundariesOnKeyManager(doScroll: boolean): void {
this.isKeyManagerFocusAlreadyOnTop = false;
this.isKeyManagerFocusAlreadyOnBottom = false;
if (doScroll)
this.element._options.get(0)?._getHostElement().scrollIntoView();
}

protected _scrollToOption(): void {
const index = this.element._keyManager.activeItemIndex || 0;
const labelCount = _countGroupLabelsBeforeOption(
Expand Down
Expand Up @@ -15,7 +15,6 @@
*/

import { ActiveDescendantKeyManager, Highlightable } from '@angular/cdk/a11y';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import { TemplatePortal } from '@angular/cdk/portal';
// tslint:disable: template-cyclomatic-complexity
import {
Expand Down Expand Up @@ -64,19 +63,6 @@ export class DtFilterFieldMultiSelectSubmittedEvent<T> {
export class DtFilterFieldMultiSelect<T>
implements DtFilterFieldElement<T>, AfterViewInit
{
/**
* Whether the first option should be highlighted when the multi-select panel is opened.
* Can be configured globally through the `DT_MULTI_SELECT_DEFAULT_OPTIONS` token.
*/
@Input()
get autoActiveFirstOption(): boolean {
return this._autoActiveFirstOption;
}
set autoActiveFirstOption(value: boolean) {
this._autoActiveFirstOption = coerceBooleanProperty(value);
}
private _autoActiveFirstOption: boolean;

/**
* Specify the width of the multi-select panel. Can be any CSS sizing value, otherwise it will
* match the width of its host.
Expand Down Expand Up @@ -179,7 +165,7 @@ export class DtFilterFieldMultiSelect<T>
// init keymanager with options
this._keyManager = new ActiveDescendantKeyManager<DtOption<T>>(
this._options,
).withWrap();
);

this._options.changes
.pipe(
Expand Down
Expand Up @@ -45,6 +45,8 @@ import {
getMultiselectCheckboxInputs,
getMultiselectCheckboxLabels,
getOptions,
moveKeyDown,
moveKeyUp,
setupFilterFieldTest,
TestApp,
} from '../testing/filter-field-test-helpers';
Expand All @@ -53,6 +55,7 @@ describe('DtFilterField', () => {
let fixture: ComponentFixture<TestApp>;
let overlayContainerElement: HTMLElement;
let filterField: DtFilterField<any>;
let inputElement: HTMLInputElement;
let advanceFilterfieldCycle: (
simulateMicrotasks?: boolean,
simulateZoneExit?: boolean,
Expand All @@ -62,10 +65,14 @@ describe('DtFilterField', () => {
nthOption: number,
) => void;

const scrollIntoViewMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;

beforeEach(() => {
({
fixture,
filterField,
inputElement: inputElement,
overlayContainerElement,
advanceFilterfieldCycle,
getAndClickOption,
Expand Down Expand Up @@ -99,6 +106,85 @@ describe('DtFilterField', () => {
expect(filterFieldMultiSelectElements.length).toBe(1);
});

describe('custom keyboard navigation', () => {
it('should have selected the last active item (ignore disabled ones) on panel after using up arrow', () => {
getAndClickOption(overlayContainerElement, 0);

inputElement.dispatchEvent(moveKeyUp());
fixture.detectChanges();
const options = getOptions(overlayContainerElement);
const lastActiveOption = options[3];
const lastOptionButDisabled = options[4];
expect(lastActiveOption.className).toContain('dt-option-active');
expect(lastOptionButDisabled.className).toContain('dt-option');
});

it('should have selected the first active item on panel after using down arrow', () => {
inputElement.dispatchEvent(moveKeyDown());
fixture.detectChanges();
const firstActiveOption = getOptions(overlayContainerElement)[0];
expect(firstActiveOption.className).toContain('dt-option-active');
});

it('should not have active items on panel after updating filter field input value', () => {
// previously the focus must be on some item of the panel
inputElement.dispatchEvent(moveKeyDown());
fixture.detectChanges();
const firstActiveOption = getOptions(overlayContainerElement)[0];
expect(firstActiveOption.className).toContain('dt-option-active');

// update input value to trigger input value change
inputElement.value = 'Mayo';
fixture.detectChanges();
const options = getOptions(overlayContainerElement);
options.forEach((option) => {
expect(option.className).toContain('dt-option');
});
});

it('should not have active items on panel after updating filter field input value', () => {
// previously the focus must be on some item of the panel
inputElement.dispatchEvent(moveKeyDown());
fixture.detectChanges();
const firstActiveOption = getOptions(overlayContainerElement)[0];
expect(firstActiveOption.className).toContain('dt-option-active');

// update input value to trigger input value change
inputElement.value = 'Mayo';
fixture.detectChanges();
const options = getOptions(overlayContainerElement);
options.forEach((option) => {
expect(option.className).toContain('dt-option');
});
});

it('should focus on input after having first active option selected and then use up arrow', () => {
inputElement.dispatchEvent(moveKeyDown());
fixture.detectChanges();
const firstActiveOption = getOptions(overlayContainerElement)[0];
expect(firstActiveOption.className).toContain('dt-option-active');

inputElement.dispatchEvent(moveKeyUp());
fixture.detectChanges();
expect(inputElement.getAttribute('readonly')).not.toBe('');
});

it('should focus on input after having last active option selected and then use down arrow', async () => {
getAndClickOption(overlayContainerElement, 0);

inputElement.dispatchEvent(moveKeyUp());
fixture.detectChanges();
const options = getOptions(overlayContainerElement);
const lastActiveOption = options[3];
expect(lastActiveOption.className).toContain('dt-option-active');
expect(inputElement.getAttribute('readonly')).toBe('');

inputElement.dispatchEvent(moveKeyDown());
fixture.detectChanges();
expect(inputElement.getAttribute('readonly')).not.toBe('');
});
});

describe('opened', () => {
beforeEach(() => {
// Open the filter-field-multiSelect overlay.
Expand Down Expand Up @@ -133,12 +219,6 @@ describe('DtFilterField', () => {
expect(applyButton).toBeDefined();
});

it('should set the focus onto the first checkbox by default', () => {
const firstOption = getOptions(overlayContainerElement)[0];

expect(firstOption.className).toContain('dt-option-active');
});

it('should keep the apply button disabled until something is selected', () => {
const checkboxes = getMultiselectCheckboxInputs(
overlayContainerElement,
Expand Down
4 changes: 3 additions & 1 deletion libs/barista-components/filter-field/src/filter-field.html
Expand Up @@ -36,7 +36,6 @@
type="text"
class="dt-filter-field-input"
[id]="_id"
[readonly]="loading"
[attr.aria-label]="ariaLabel"
[dtAutocomplete]="autocomplete"
[dtAutocompleteDisabled]="!_autocompleteOptionsOrGroups.length || loading"
Expand All @@ -50,8 +49,11 @@
"
(keydown)="_handleInputKeyDown($event)"
(keyup)="_handleInputKeyUp($event)"
(focus)="_handleFocus()"
(mousemove)="_handleMouseMove()"
[value]="_inputValue"
[disabled]="disabled"
[readonly]="loading || isOptionOnMultiSelectSelected"
/>
<dt-autocomplete
#autocomplete="dtAutocomplete"
Expand Down

0 comments on commit 939fb17

Please sign in to comment.