Skip to content

Commit 53e0418

Browse files
committed
refactor(multiple): add simple-combobox unit tests and refine interaction logic
- Adds extensive unit tests for SimpleCombobox across Listbox, Tree, and Grid implementations. - Refines aria-autocomplete calculation to exclude dialog-type popups, as they do not support autocomplete behavior. - Switches SimpleCombobox from pointerdown to click for popup triggering to improve interaction consistency. - Fixes ListFocus to properly focus the host element when using activedescendant mode. - Updates GridPattern to prevent resetting its default active state once a user has already interacted with it. - Moves alwaysExpanded initialization to ngOnInit in the public Combobox component for better lifecycle management. - Improves simple-combobox-highlight example to handle disabled states.
1 parent 829959d commit 53e0418

15 files changed

Lines changed: 2014 additions & 93 deletions

File tree

src/aria/listbox/listbox.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,10 @@ describe('Listbox', () => {
148148
expect(listboxElement.getAttribute('aria-multiselectable')).toBe('false');
149149
});
150150

151-
it('should set aria-selected to "false" for all options by default', () => {
152-
optionElements.forEach(optionElement => {
153-
expect(optionElement.getAttribute('aria-selected')).toBe('false');
154-
});
151+
it('should set aria-selected to "true" for the first option and "false" for others by default', () => {
152+
expect(optionElements[0].getAttribute('aria-selected')).toBe('true');
153+
expect(optionElements[1].getAttribute('aria-selected')).toBe('false');
154+
expect(optionElements[2].getAttribute('aria-selected')).toBe('false');
155155
});
156156
});
157157

src/aria/private/behaviors/grid/grid-focus.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export class GridFocus<T extends GridFocusCell> {
9898
});
9999

100100
/** The tab index for the grid container. */
101-
readonly gridTabIndex = computed<-1 | 0 | null>(() => {
101+
readonly gridTabIndex = computed<-1 | 0>(() => {
102102
const isTabbable = this.inputs.tabbable?.();
103103
if (isTabbable === false) {
104104
return -1;

src/aria/private/behaviors/grid/grid.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export class Grid<T extends GridCell> {
8383
);
8484

8585
/** The tab index for the grid container. */
86-
readonly gridTabIndex: SignalLike<-1 | 0 | null> = () => this.focusBehavior.gridTabIndex();
86+
readonly gridTabIndex: SignalLike<-1 | 0> = () => this.focusBehavior.gridTabIndex();
8787

8888
/** Whether the grid is in a disabled state. */
8989
readonly gridDisabled: SignalLike<boolean> = () => this.focusBehavior.gridDisabled();

src/aria/private/behaviors/list-focus/list-focus.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ export class ListFocus<T extends ListFocusItem> {
114114
if (opts?.focusElement || opts?.focusElement === undefined) {
115115
if (this.inputs.focusMode() === 'roving') {
116116
item.element()?.focus();
117+
} else if (this.inputs.focusMode() === 'activedescendant') {
118+
this.inputs.element()?.focus();
117119
}
118120
}
119121

src/aria/private/grid/grid.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,8 +275,8 @@ describe('Grid', () => {
275275
});
276276

277277
it('should trigger click on Enter for simple widget', () => {
278-
const {grid} = createGrid([{cells: [{widgets: [{widgetType: 'simple'}]}]}], gridInputs);
279-
const widget = grid.cells()[0][0].inputs.widgets()[0];
278+
const {grid} = createGrid([{cells: [{widget: {widgetType: 'simple'}}]}], gridInputs);
279+
const widget = grid.cells()[0][0].inputs.widget()!;
280280
const element = widget.element();
281281
spyOn(element, 'click');
282282

src/aria/private/grid/grid.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ export class GridPattern {
256256

257257
/** Sets the default active state of the grid before receiving focus the first time. */
258258
setDefaultStateEffect(): void {
259+
if (this.hasBeenInteracted()) return;
259260
this.gridBehavior.setDefaultState();
260261
}
261262

src/aria/private/simple-combobox/BUILD.bazel

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

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

@@ -16,3 +16,23 @@ ts_project(
1616
"//src/aria/private/behaviors/signal-like",
1717
],
1818
)
19+
20+
ts_project(
21+
name = "unit_test_sources",
22+
testonly = True,
23+
srcs = glob(["**/*.spec.ts"]),
24+
deps = [
25+
":simple-combobox",
26+
"//:node_modules/@angular/core",
27+
"//src/aria/private/behaviors/signal-like",
28+
"//src/aria/private/listbox",
29+
"//src/aria/private/tree",
30+
"//src/cdk/keycodes",
31+
"//src/cdk/testing/private",
32+
],
33+
)
34+
35+
ng_web_test_suite(
36+
name = "unit_tests",
37+
deps = [":unit_test_sources"],
38+
)
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import {SimpleComboboxPattern, SimpleComboboxPopupPattern} from './simple-combobox';
2+
import {signal} from '../behaviors/signal-like/signal-like';
3+
import {createKeyboardEvent} from '@angular/cdk/testing/private';
4+
5+
describe('SimpleComboboxPattern', () => {
6+
function setup(
7+
inputs: Partial<{
8+
disabled: boolean;
9+
alwaysExpanded: boolean;
10+
inlineSuggestion: string;
11+
popupType: 'listbox' | 'tree' | 'grid' | 'dialog';
12+
}> = {},
13+
) {
14+
const element = document.createElement('input');
15+
const value = signal('');
16+
const expanded = signal(false);
17+
const alwaysExpanded = signal(inputs.alwaysExpanded ?? false);
18+
const disabled = signal(inputs.disabled ?? false);
19+
const inlineSuggestion = signal<string | undefined>(inputs.inlineSuggestion);
20+
21+
// Mock a generic popup pattern
22+
const popupId = signal('popup-1');
23+
const activeDescendant = signal<string | undefined>('item-1');
24+
const controlTarget = document.createElement('div');
25+
const popupType = signal<'listbox' | 'tree' | 'grid' | 'dialog'>(inputs.popupType ?? 'listbox');
26+
27+
const popup = new SimpleComboboxPopupPattern({
28+
popupType,
29+
controlTarget: signal(controlTarget),
30+
activeDescendant,
31+
popupId,
32+
});
33+
34+
const pattern = new SimpleComboboxPattern({
35+
alwaysExpanded,
36+
value,
37+
element: signal(element),
38+
popup: signal(popup),
39+
inlineSuggestion,
40+
disabled,
41+
expanded,
42+
expandable: signal(true),
43+
});
44+
45+
return {
46+
pattern,
47+
element,
48+
value,
49+
expanded,
50+
alwaysExpanded,
51+
inlineSuggestion,
52+
disabled,
53+
popup,
54+
controlTarget,
55+
};
56+
}
57+
58+
describe('Aria-autocomplete calculation', () => {
59+
it('should return "list" when only popup is present', () => {
60+
const {pattern} = setup();
61+
expect(pattern.autocomplete()).toBe('list');
62+
});
63+
64+
it('should return "both" when popup and inline suggestion are present', () => {
65+
const {pattern} = setup({inlineSuggestion: 'suggestion'});
66+
expect(pattern.autocomplete()).toBe('both');
67+
});
68+
69+
it('should return "none" when only dialog popup is present', () => {
70+
const {pattern} = setup({popupType: 'dialog'});
71+
expect(pattern.autocomplete()).toBe('none');
72+
});
73+
74+
it('should return "inline" when dialog popup and inline suggestion are present', () => {
75+
const {pattern} = setup({popupType: 'dialog', inlineSuggestion: 'suggestion'});
76+
expect(pattern.autocomplete()).toBe('inline');
77+
});
78+
});
79+
80+
describe('Expansion via Keyboard', () => {
81+
it('should open on ArrowDown when collapsed', () => {
82+
const {pattern, expanded} = setup();
83+
expect(expanded()).toBe(false);
84+
85+
pattern.onKeydown(createKeyboardEvent('keydown', 40, 'ArrowDown'));
86+
expect(expanded()).toBe(true);
87+
});
88+
89+
it('should close on Escape when expanded', () => {
90+
const {pattern, expanded} = setup();
91+
expanded.set(true);
92+
93+
pattern.onKeydown(createKeyboardEvent('keydown', 27, 'Escape'));
94+
expect(expanded()).toBe(false);
95+
});
96+
});
97+
98+
describe('Input handling', () => {
99+
it('should update value and expand on input', () => {
100+
const {pattern, element, value, expanded} = setup();
101+
expect(expanded()).toBe(false);
102+
103+
element.value = 'hello';
104+
pattern.onInput({target: element} as unknown as Event);
105+
106+
expect(value()).toBe('hello');
107+
expect(expanded()).toBe(true);
108+
});
109+
});
110+
111+
describe('Focus handling', () => {
112+
it('should track focus state', () => {
113+
const {pattern} = setup();
114+
115+
pattern.onFocusin();
116+
expect(pattern.isFocused()).toBe(true);
117+
118+
pattern.onFocusout(new FocusEvent('focusout'));
119+
expect(pattern.isFocused()).toBe(false);
120+
});
121+
});
122+
123+
describe('Inline Suggestion / Highlighting', () => {
124+
it('should insert the inline suggestion into the input and select the remaining text', () => {
125+
const {pattern, element, value, expanded, inlineSuggestion} = setup();
126+
127+
value.set('App');
128+
inlineSuggestion.set('Apple');
129+
expanded.set(true);
130+
pattern.isFocused.set(true);
131+
132+
pattern.highlightEffect();
133+
134+
expect(element.value).toBe('Apple');
135+
expect(element.selectionStart).toBe(3);
136+
expect(element.selectionEnd).toBe(5);
137+
});
138+
139+
it('should not highlight when deleting text', () => {
140+
const {pattern, element, value, expanded, inlineSuggestion} = setup();
141+
142+
value.set('App');
143+
inlineSuggestion.set('Apple');
144+
expanded.set(true);
145+
pattern.isFocused.set(true);
146+
147+
const deleteEvent = new InputEvent('input', {inputType: 'deleteContentBackward'});
148+
Object.defineProperty(deleteEvent, 'target', {value: element});
149+
pattern.onInput(deleteEvent as Event);
150+
151+
expect(pattern.isDeleting()).toBe(true);
152+
153+
pattern.highlightEffect();
154+
155+
expect(element.value).not.toBe('Apple');
156+
});
157+
});
158+
159+
describe('Select-only combobox behavior', () => {
160+
function setupSelectOnly() {
161+
const selectOnlyElement = document.createElement('div');
162+
const {pattern, expanded, controlTarget} = setup();
163+
164+
// Override element to be select-only
165+
pattern.inputs.element = signal(selectOnlyElement);
166+
167+
return {pattern, expanded, selectOnlyElement, controlTarget};
168+
}
169+
170+
it('should toggle expansion on click', () => {
171+
const {pattern, expanded} = setupSelectOnly();
172+
expect(expanded()).toBe(false);
173+
174+
pattern.onClick(new PointerEvent('click'));
175+
expect(expanded()).toBe(true);
176+
177+
pattern.onClick(new PointerEvent('click'));
178+
expect(expanded()).toBe(false);
179+
});
180+
181+
it('should open on Enter or Space when collapsed', () => {
182+
const {pattern, expanded} = setupSelectOnly();
183+
184+
pattern.onKeydown(createKeyboardEvent('keydown', 13, 'Enter'));
185+
expect(expanded()).toBe(true);
186+
187+
expanded.set(false);
188+
189+
pattern.onKeydown(createKeyboardEvent('keydown', 32, ' '));
190+
expect(expanded()).toBe(true);
191+
});
192+
});
193+
194+
describe('alwaysExpanded behavior', () => {
195+
it('should stay open on Escape when alwaysExpanded is true', () => {
196+
const {pattern, expanded} = setup({alwaysExpanded: true});
197+
expanded.set(true);
198+
199+
pattern.onKeydown(createKeyboardEvent('keydown', 27, 'Escape'));
200+
expect(expanded()).toBe(true);
201+
});
202+
});
203+
204+
describe('Blur behavior', () => {
205+
it('should close when focus leaves both combobox and popup', () => {
206+
const {pattern, expanded} = setup();
207+
expanded.set(true);
208+
pattern.isFocused.set(false);
209+
pattern.inputs.popup()!.isFocused.set(false);
210+
211+
pattern.closePopupOnBlurEffect();
212+
expect(expanded()).toBe(false);
213+
});
214+
215+
it('should remain open if popup is focused', () => {
216+
const {pattern, expanded} = setup();
217+
expanded.set(true);
218+
pattern.isFocused.set(false);
219+
pattern.inputs.popup()!.isFocused.set(true);
220+
221+
pattern.closePopupOnBlurEffect();
222+
expect(expanded()).toBe(true);
223+
});
224+
});
225+
});

0 commit comments

Comments
 (0)