Skip to content

Commit 29cd60e

Browse files
committed
feat(aria/listbox): introduce listbox harness
1 parent 754b682 commit 29cd60e

File tree

8 files changed

+326
-0
lines changed

8 files changed

+326
-0
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
## API Report File for "@angular/aria_listbox_testing"
2+
3+
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
4+
5+
```ts
6+
7+
import { BaseHarnessFilters } from '@angular/cdk/testing';
8+
import { ComponentHarness } from '@angular/cdk/testing';
9+
import { ContentContainerComponentHarness } from '@angular/cdk/testing';
10+
import { HarnessPredicate } from '@angular/cdk/testing';
11+
12+
// @public
13+
export class ListboxHarness extends ContentContainerComponentHarness<ListboxSection> {
14+
blur(): Promise<void>;
15+
focus(): Promise<void>;
16+
getOptions(filters?: ListboxOptionHarnessFilters): Promise<ListboxOptionHarness[]>;
17+
getOrientation(): Promise<'vertical' | 'horizontal'>;
18+
// (undocumented)
19+
static hostSelector: string;
20+
isDisabled(): Promise<boolean>;
21+
isMulti(): Promise<boolean>;
22+
static with(options?: ListboxHarnessFilters): HarnessPredicate<ListboxHarness>;
23+
}
24+
25+
// @public
26+
export interface ListboxHarnessFilters extends BaseHarnessFilters {
27+
disabled?: boolean;
28+
}
29+
30+
// @public
31+
export class ListboxOptionHarness extends ComponentHarness {
32+
click(): Promise<void>;
33+
getText(): Promise<string>;
34+
// (undocumented)
35+
static hostSelector: string;
36+
isDisabled(): Promise<boolean>;
37+
isSelected(): Promise<boolean>;
38+
static with(options?: ListboxOptionHarnessFilters): HarnessPredicate<ListboxOptionHarness>;
39+
}
40+
41+
// @public
42+
export interface ListboxOptionHarnessFilters extends BaseHarnessFilters {
43+
disabled?: boolean;
44+
selected?: boolean;
45+
text?: string | RegExp;
46+
}
47+
48+
// @public
49+
export enum ListboxSection {
50+
// (undocumented)
51+
OPTION = "[ngOption]"
52+
}
53+
54+
// (No @packageDocumentation comment for this package)
55+
56+
```

src/aria/config.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ ARIA_ENTRYPOINTS = [
44
"combobox",
55
"grid",
66
"listbox",
7+
"listbox/testing",
78
"menu",
89
"tabs",
910
"toolbar",

src/aria/listbox/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ ng_project(
1414
"//src/aria/private",
1515
"//src/cdk/a11y",
1616
"//src/cdk/bidi",
17+
"//src/cdk/testing",
1718
],
1819
)
1920

@@ -29,7 +30,9 @@ ng_project(
2930
"//:node_modules/@angular/core",
3031
"//:node_modules/@angular/platform-browser",
3132
"//:node_modules/axe-core",
33+
"//src/cdk/testing",
3234
"//src/cdk/testing/private",
35+
"//src/cdk/testing/testbed",
3336
],
3437
)
3538

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_project(
6+
name = "testing",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//:node_modules/@angular/core",
13+
"//src/cdk/testing",
14+
],
15+
)
16+
17+
filegroup(
18+
name = "source-files",
19+
srcs = glob(["**/*.ts"]),
20+
)
21+
22+
ng_project(
23+
name = "unit_tests_lib",
24+
testonly = True,
25+
srcs = glob(["**/*.spec.ts"]),
26+
deps = [
27+
":testing",
28+
"//:node_modules/@angular/core",
29+
"//:node_modules/@angular/platform-browser",
30+
"//src/aria/listbox",
31+
"//src/cdk/testing",
32+
"//src/cdk/testing/private",
33+
"//src/cdk/testing/testbed",
34+
],
35+
)
36+
37+
ng_web_test_suite(
38+
name = "unit_tests",
39+
deps = [
40+
":unit_tests_lib",
41+
],
42+
)

src/aria/listbox/testing/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export * from './public-api';
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {Component} from '@angular/core';
10+
import {TestBed} from '@angular/core/testing';
11+
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
12+
13+
import {ListboxHarness, ListboxOptionHarness} from './listbox-harness';
14+
import {Listbox, Option} from '../index';
15+
16+
describe('Listbox Harness', () => {
17+
let fixture: any;
18+
let loader: any;
19+
20+
@Component({
21+
imports: [Listbox, Option],
22+
template: `
23+
<ul ngListbox [disabled]="false" [multi]="true" orientation="horizontal">
24+
<li ngOption [value]="1" label="Apple" aria-selected="true">Apple</li>
25+
<li ngOption [value]="2" label="Banana">Banana</li>
26+
<div class="test-item">Inside Listbox</div>
27+
</ul>
28+
`,
29+
})
30+
class ListboxHarnessTestComponent {}
31+
32+
beforeEach(() => {
33+
TestBed.configureTestingModule({
34+
imports: [ListboxHarnessTestComponent],
35+
});
36+
fixture = TestBed.createComponent(ListboxHarnessTestComponent);
37+
fixture.detectChanges();
38+
loader = TestbedHarnessEnvironment.loader(fixture);
39+
});
40+
41+
it('finds the listbox container harness', async () => {
42+
const listbox = await loader.getHarness(ListboxHarness);
43+
expect(listbox).toBeTruthy();
44+
});
45+
46+
it('returns all options scoped within the listbox', async () => {
47+
const listbox = await loader.getHarness(ListboxHarness);
48+
49+
const options = await listbox.getOptions();
50+
51+
expect(options.length).toBe(2);
52+
});
53+
54+
it('filters options by exact text content', async () => {
55+
const listbox = await loader.getHarness(ListboxHarness);
56+
57+
const options = await listbox.getOptions({text: 'Apple'});
58+
59+
expect(options.length).toBe(1);
60+
});
61+
62+
it('reports the disabled state of the listbox', async () => {
63+
const listbox = await loader.getHarness(ListboxHarness);
64+
65+
const isDisabled = await listbox.isDisabled();
66+
67+
expect(isDisabled).toBeFalse();
68+
});
69+
70+
it('reports the multi-selectable state of the listbox', async () => {
71+
const listbox = await loader.getHarness(ListboxHarness);
72+
73+
const isMulti = await listbox.isMulti();
74+
75+
expect(isMulti).toBeTrue();
76+
});
77+
78+
it('reports the orientation of the listbox', async () => {
79+
const listbox = await loader.getHarness(ListboxHarness);
80+
81+
const orientation = await listbox.getOrientation();
82+
83+
expect(orientation).toBe('horizontal');
84+
});
85+
86+
it('clicks an option inside the listbox', async () => {
87+
const option = await loader.getHarness(ListboxOptionHarness.with({text: 'Apple'}));
88+
89+
await option.click();
90+
91+
expect(await option.isSelected()).toBeTrue();
92+
});
93+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {ComponentHarness, HarnessPredicate, BaseHarnessFilters} from '@angular/cdk/testing';
10+
11+
/** Selectors for the listbox sections. */
12+
export enum ListboxSection {
13+
OPTION = '[ngOption]',
14+
}
15+
16+
/** Filters for locating a `ListboxOptionHarness`. */
17+
export interface ListboxOptionHarnessFilters extends BaseHarnessFilters {
18+
/** Only find instances whose text matches the given value. */
19+
text?: string | RegExp;
20+
/** Only find instances whose selected state matches the given value. */
21+
selected?: boolean;
22+
/** Only find instances whose disabled state matches the given value. */
23+
disabled?: boolean;
24+
}
25+
26+
export class ListboxOptionHarness extends ComponentHarness {
27+
static hostSelector = '[ngOption]';
28+
29+
static with(options: ListboxOptionHarnessFilters = {}): HarnessPredicate<ListboxOptionHarness> {
30+
return new HarnessPredicate(ListboxOptionHarness, options)
31+
.addOption('text', options.text, (harness, text) =>
32+
HarnessPredicate.stringMatches(harness.getText(), text),
33+
)
34+
.addOption(
35+
'selected',
36+
options.selected,
37+
async (harness, selected) => (await harness.isSelected()) === selected,
38+
)
39+
.addOption(
40+
'disabled',
41+
options.disabled,
42+
async (harness, disabled) => (await harness.isDisabled()) === disabled,
43+
);
44+
}
45+
46+
async isSelected(): Promise<boolean> {
47+
const host = await this.host();
48+
return (await host.getAttribute('aria-selected')) === 'true';
49+
}
50+
51+
async isDisabled(): Promise<boolean> {
52+
const host = await this.host();
53+
return (
54+
(await host.getAttribute('aria-disabled')) === 'true' ||
55+
(await host.getProperty('disabled')) === true
56+
);
57+
}
58+
59+
async getText(): Promise<string> {
60+
const host = await this.host();
61+
return host.text();
62+
}
63+
64+
async click(): Promise<void> {
65+
const host = await this.host();
66+
return host.click();
67+
}
68+
}
69+
70+
export interface ListboxHarnessFilters extends BaseHarnessFilters {
71+
disabled?: boolean;
72+
}
73+
74+
export class ListboxHarness extends ComponentHarness {
75+
static hostSelector = '[ngListbox]';
76+
77+
static with(options: ListboxHarnessFilters = {}): HarnessPredicate<ListboxHarness> {
78+
return new HarnessPredicate(ListboxHarness, options).addOption(
79+
'disabled',
80+
options.disabled,
81+
async (harness, disabled) => (await harness.isDisabled()) === disabled,
82+
);
83+
}
84+
85+
async getOrientation(): Promise<'vertical' | 'horizontal'> {
86+
const host = await this.host();
87+
const orientation = await host.getAttribute('aria-orientation');
88+
return orientation === 'horizontal' ? 'horizontal' : 'vertical';
89+
}
90+
91+
async isMulti(): Promise<boolean> {
92+
const host = await this.host();
93+
return (await host.getAttribute('aria-multiselectable')) === 'true';
94+
}
95+
96+
async isDisabled(): Promise<boolean> {
97+
const host = await this.host();
98+
return (await host.getAttribute('aria-disabled')) === 'true';
99+
}
100+
101+
async getOptions(filters: ListboxOptionHarnessFilters = {}): Promise<ListboxOptionHarness[]> {
102+
return this.locatorForAll(ListboxOptionHarness.with(filters))();
103+
}
104+
105+
async focus(): Promise<void> {
106+
await (await this.host()).focus();
107+
}
108+
109+
/** Blurs the listbox container. */
110+
async blur(): Promise<void> {
111+
await (await this.host()).blur();
112+
}
113+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
export * from './listbox-harness';

0 commit comments

Comments
 (0)