Skip to content

Commit 2a97418

Browse files
authored
feat(cdk-experimental/listbox): selection logic and testing for listbox. (#19690)
1 parent 286fd99 commit 2a97418

File tree

3 files changed

+195
-2
lines changed

3 files changed

+195
-2
lines changed

src/cdk-experimental/listbox/BUILD.bazel

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
load("//tools:defaults.bzl", "ng_module")
1+
load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite")
22

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

@@ -9,4 +9,25 @@ ng_module(
99
exclude = ["**/*.spec.ts"],
1010
),
1111
module_name = "@angular/cdk-experimental/listbox",
12+
deps = [
13+
"//src/cdk/coercion",
14+
],
15+
)
16+
17+
ng_test_library(
18+
name = "unit_test_sources",
19+
srcs = glob(
20+
["**/*.spec.ts"],
21+
exclude = ["**/*.e2e.spec.ts"],
22+
),
23+
deps = [
24+
":listbox",
25+
"//src/cdk/testing/private",
26+
"@npm//@angular/platform-browser",
27+
],
28+
)
29+
30+
ng_web_test_suite(
31+
name = "unit_tests",
32+
deps = [":unit_test_sources"],
1233
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {
2+
ComponentFixture,
3+
async,
4+
TestBed,
5+
} from '@angular/core/testing';
6+
import {Component, DebugElement} from '@angular/core';
7+
import {By} from '@angular/platform-browser';
8+
import {
9+
CdkOption,
10+
CdkListboxModule, ListboxSelectionChangeEvent
11+
} from './index';
12+
import {dispatchMouseEvent} from '@angular/cdk/testing/private';
13+
14+
describe('CdkOption', () => {
15+
16+
describe('selection state change', () => {
17+
let fixture: ComponentFixture<ListboxWithOptions>;
18+
let options: DebugElement[];
19+
let optionInstances: CdkOption[];
20+
let optionElements: HTMLElement[];
21+
22+
beforeEach(async(() => {
23+
TestBed.configureTestingModule({
24+
imports: [CdkListboxModule],
25+
declarations: [ListboxWithOptions],
26+
}).compileComponents();
27+
}));
28+
29+
beforeEach(async(() => {
30+
fixture = TestBed.createComponent(ListboxWithOptions);
31+
fixture.detectChanges();
32+
33+
options = fixture.debugElement.queryAll(By.directive(CdkOption));
34+
optionInstances = options.map(o => o.injector.get<CdkOption>(CdkOption));
35+
optionElements = options.map(o => o.nativeElement);
36+
}));
37+
38+
it('should generate a unique optionId for each option', () => {
39+
let optionIds: string[] = [];
40+
for (const instance of optionInstances) {
41+
expect(optionIds.indexOf(instance.id)).toBe(-1);
42+
optionIds.push(instance.id);
43+
44+
expect(instance.id).toMatch(/cdk-option-\d+/);
45+
}
46+
});
47+
48+
it('should have set the selected input of the options to null by default', () => {
49+
for (const instance of optionInstances) {
50+
expect(instance.selected).toBeFalse();
51+
}
52+
});
53+
54+
it('should update aria-selected when selected is changed programmatically', () => {
55+
expect(optionElements[0].getAttribute('aria-selected')).toBeNull();
56+
optionInstances[1].selected = true;
57+
fixture.detectChanges();
58+
59+
expect(optionElements[1].getAttribute('aria-selected')).toBe('true');
60+
});
61+
62+
it('should update selected option on click event', () => {
63+
let selectedOptions = optionInstances.filter(option => option.selected);
64+
65+
expect(selectedOptions.length).toBe(0);
66+
expect(optionElements[0].getAttribute('aria-selected')).toBeNull();
67+
expect(optionInstances[0].selected).toBeFalse();
68+
expect(fixture.componentInstance.changedOption).toBeUndefined();
69+
70+
dispatchMouseEvent(optionElements[0], 'click');
71+
fixture.detectChanges();
72+
73+
selectedOptions = optionInstances.filter(option => option.selected);
74+
expect(selectedOptions.length).toBe(1);
75+
expect(optionElements[0].getAttribute('aria-selected')).toBe('true');
76+
expect(optionInstances[0].selected).toBeTrue();
77+
expect(fixture.componentInstance.changedOption).toBeDefined();
78+
expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id);
79+
});
80+
});
81+
82+
});
83+
84+
@Component({
85+
template: `
86+
<div cdkListbox (selectionChange)="onSelectionChange($event)">
87+
<div cdkOption>Void</div>
88+
<div cdkOption>Solar</div>
89+
<div cdkOption>Arc</div>
90+
<div cdkOption>Stasis</div>
91+
</div>`
92+
})
93+
class ListboxWithOptions {
94+
changedOption: CdkOption;
95+
96+
onSelectionChange(event: ListboxSelectionChangeEvent) {
97+
this.changedOption = event.option;
98+
}
99+
}

src/cdk-experimental/listbox/listbox.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,63 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Directive} from '@angular/core';
9+
import {
10+
ContentChildren,
11+
Directive,
12+
EventEmitter, forwardRef, Inject,
13+
Input, Output,
14+
QueryList
15+
} from '@angular/core';
16+
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
1017

18+
let nextId = 0;
19+
20+
/**
21+
* Directive that applies interaction patterns to an element following the aria role of option.
22+
* Typically meant to be placed inside a listbox. Logic handling selection, disabled state, and
23+
* value is built in.
24+
*/
1125
@Directive({
1226
selector: '[cdkOption]',
1327
exportAs: 'cdkOption',
1428
host: {
1529
role: 'option',
30+
'(click)': 'toggle()',
31+
'[attr.aria-selected]': 'selected || null',
32+
'[id]': 'id',
1633
}
1734
})
1835
export class CdkOption {
36+
private _selected: boolean = false;
37+
38+
/** Whether the option is selected or not */
39+
@Input()
40+
get selected(): boolean {
41+
return this._selected;
42+
}
43+
set selected(value: boolean) {
44+
this._selected = coerceBooleanProperty(value);
45+
}
1946

47+
/** The id of the option, set to a uniqueid if the user does not provide one */
48+
@Input() id = `cdk-option-${nextId++}`;
49+
50+
constructor(@Inject(forwardRef(() => CdkListbox)) public listbox: CdkListbox) {}
51+
52+
/** Toggles the selected state, emits a change event through the injected listbox */
53+
toggle() {
54+
this.selected = !this.selected;
55+
this.listbox._emitChangeEvent(this);
56+
}
57+
58+
static ngAcceptInputType_selected: BooleanInput;
2059
}
2160

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

75+
/** A query list containing all CdkOption elements within this listbox */
76+
@ContentChildren(CdkOption, {descendants: true}) _options: QueryList<CdkOption>;
77+
78+
@Output() readonly selectionChange: EventEmitter<ListboxSelectionChangeEvent> =
79+
new EventEmitter<ListboxSelectionChangeEvent>();
80+
81+
/** Emits a selection change event, called when an option has its selected state changed */
82+
_emitChangeEvent(option: CdkOption) {
83+
this.selectionChange.emit(new ListboxSelectionChangeEvent(this, option));
84+
}
85+
86+
/** Sets the given option's selected state to true */
87+
select(option: CdkOption) {
88+
option.selected = true;
89+
}
90+
91+
/** Sets the given option's selected state to null. Null is preferable for screen readers */
92+
deselect(option: CdkOption) {
93+
option.selected = false;
94+
}
95+
}
96+
97+
/** Change event that is being fired whenever the selected state of an option changes. */
98+
export class ListboxSelectionChangeEvent {
99+
constructor(
100+
/** Reference to the listbox that emitted the event. */
101+
public source: CdkListbox,
102+
/** Reference to the option that has been changed. */
103+
public option: CdkOption) {}
31104
}

0 commit comments

Comments
 (0)