Skip to content

Commit

Permalink
feat(cdk-experimental/listbox): selection logic and testing for listb…
Browse files Browse the repository at this point in the history
…ox. (#19690)
  • Loading branch information
nielsr98 authored Jun 26, 2020
1 parent 286fd99 commit 2a97418
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 2 deletions.
23 changes: 22 additions & 1 deletion src/cdk-experimental/listbox/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
load("//tools:defaults.bzl", "ng_module")
load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite")

package(default_visibility = ["//visibility:public"])

Expand All @@ -9,4 +9,25 @@ ng_module(
exclude = ["**/*.spec.ts"],
),
module_name = "@angular/cdk-experimental/listbox",
deps = [
"//src/cdk/coercion",
],
)

ng_test_library(
name = "unit_test_sources",
srcs = glob(
["**/*.spec.ts"],
exclude = ["**/*.e2e.spec.ts"],
),
deps = [
":listbox",
"//src/cdk/testing/private",
"@npm//@angular/platform-browser",
],
)

ng_web_test_suite(
name = "unit_tests",
deps = [":unit_test_sources"],
)
99 changes: 99 additions & 0 deletions src/cdk-experimental/listbox/listbox.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
ComponentFixture,
async,
TestBed,
} from '@angular/core/testing';
import {Component, DebugElement} from '@angular/core';
import {By} from '@angular/platform-browser';
import {
CdkOption,
CdkListboxModule, ListboxSelectionChangeEvent
} from './index';
import {dispatchMouseEvent} from '@angular/cdk/testing/private';

describe('CdkOption', () => {

describe('selection state change', () => {
let fixture: ComponentFixture<ListboxWithOptions>;
let options: DebugElement[];
let optionInstances: CdkOption[];
let optionElements: HTMLElement[];

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CdkListboxModule],
declarations: [ListboxWithOptions],
}).compileComponents();
}));

beforeEach(async(() => {
fixture = TestBed.createComponent(ListboxWithOptions);
fixture.detectChanges();

options = fixture.debugElement.queryAll(By.directive(CdkOption));
optionInstances = options.map(o => o.injector.get<CdkOption>(CdkOption));
optionElements = options.map(o => o.nativeElement);
}));

it('should generate a unique optionId for each option', () => {
let optionIds: string[] = [];
for (const instance of optionInstances) {
expect(optionIds.indexOf(instance.id)).toBe(-1);
optionIds.push(instance.id);

expect(instance.id).toMatch(/cdk-option-\d+/);
}
});

it('should have set the selected input of the options to null by default', () => {
for (const instance of optionInstances) {
expect(instance.selected).toBeFalse();
}
});

it('should update aria-selected when selected is changed programmatically', () => {
expect(optionElements[0].getAttribute('aria-selected')).toBeNull();
optionInstances[1].selected = true;
fixture.detectChanges();

expect(optionElements[1].getAttribute('aria-selected')).toBe('true');
});

it('should update selected option on click event', () => {
let selectedOptions = optionInstances.filter(option => option.selected);

expect(selectedOptions.length).toBe(0);
expect(optionElements[0].getAttribute('aria-selected')).toBeNull();
expect(optionInstances[0].selected).toBeFalse();
expect(fixture.componentInstance.changedOption).toBeUndefined();

dispatchMouseEvent(optionElements[0], 'click');
fixture.detectChanges();

selectedOptions = optionInstances.filter(option => option.selected);
expect(selectedOptions.length).toBe(1);
expect(optionElements[0].getAttribute('aria-selected')).toBe('true');
expect(optionInstances[0].selected).toBeTrue();
expect(fixture.componentInstance.changedOption).toBeDefined();
expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id);
});
});

});

@Component({
template: `
<div cdkListbox (selectionChange)="onSelectionChange($event)">
<div cdkOption>Void</div>
<div cdkOption>Solar</div>
<div cdkOption>Arc</div>
<div cdkOption>Stasis</div>
</div>`
})
class ListboxWithOptions {
changedOption: CdkOption;

onSelectionChange(event: ListboxSelectionChangeEvent) {
this.changedOption = event.option;
}
}
75 changes: 74 additions & 1 deletion src/cdk-experimental/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,63 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directive} from '@angular/core';
import {
ContentChildren,
Directive,
EventEmitter, forwardRef, Inject,
Input, Output,
QueryList
} from '@angular/core';
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';

let nextId = 0;

/**
* Directive that applies interaction patterns to an element following the aria role of option.
* Typically meant to be placed inside a listbox. Logic handling selection, disabled state, and
* value is built in.
*/
@Directive({
selector: '[cdkOption]',
exportAs: 'cdkOption',
host: {
role: 'option',
'(click)': 'toggle()',
'[attr.aria-selected]': 'selected || null',
'[id]': 'id',
}
})
export class CdkOption {
private _selected: boolean = false;

/** Whether the option is selected or not */
@Input()
get selected(): boolean {
return this._selected;
}
set selected(value: boolean) {
this._selected = coerceBooleanProperty(value);
}

/** The id of the option, set to a uniqueid if the user does not provide one */
@Input() id = `cdk-option-${nextId++}`;

constructor(@Inject(forwardRef(() => CdkListbox)) public listbox: CdkListbox) {}

/** Toggles the selected state, emits a change event through the injected listbox */
toggle() {
this.selected = !this.selected;
this.listbox._emitChangeEvent(this);
}

static ngAcceptInputType_selected: BooleanInput;
}

/**
* Directive that applies interaction patterns to an element following the aria role of listbox.
* Typically CdkOption elements are placed inside the listbox. Logic to handle keyboard navigation,
* selection of options, active options, and disabled states is built in.
*/
@Directive({
selector: '[cdkListbox]',
exportAs: 'cdkListbox',
Expand All @@ -28,4 +72,33 @@ export class CdkOption {
})
export class CdkListbox {

/** A query list containing all CdkOption elements within this listbox */
@ContentChildren(CdkOption, {descendants: true}) _options: QueryList<CdkOption>;

@Output() readonly selectionChange: EventEmitter<ListboxSelectionChangeEvent> =
new EventEmitter<ListboxSelectionChangeEvent>();

/** Emits a selection change event, called when an option has its selected state changed */
_emitChangeEvent(option: CdkOption) {
this.selectionChange.emit(new ListboxSelectionChangeEvent(this, option));
}

/** Sets the given option's selected state to true */
select(option: CdkOption) {
option.selected = true;
}

/** Sets the given option's selected state to null. Null is preferable for screen readers */
deselect(option: CdkOption) {
option.selected = false;
}
}

/** Change event that is being fired whenever the selected state of an option changes. */
export class ListboxSelectionChangeEvent {
constructor(
/** Reference to the listbox that emitted the event. */
public source: CdkListbox,
/** Reference to the option that has been changed. */
public option: CdkOption) {}
}

0 comments on commit 2a97418

Please sign in to comment.