Skip to content

Commit 7b9186b

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 7b9186b

13 files changed

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

src/aria/private/simple-combobox/simple-combobox.ts

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager';
10-
import {afterRenderEffect, computed, signal, untracked} from '@angular/core';
9+
import {KeyboardEventManager, ClickEventManager, Modifier} from '../behaviors/event-manager';
10+
import {computed, signal, untracked} from '@angular/core';
1111
import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like';
1212
import {ExpansionItem} from '../behaviors/expansion/expansion';
1313

@@ -60,12 +60,13 @@ export class SimpleComboboxPattern {
6060

6161
/** The autocomplete behavior of the combobox. */
6262
readonly autocomplete = computed<'none' | 'inline' | 'list' | 'both'>(() => {
63-
const hasPopup = !!this.inputs.popup();
63+
const popupType = this.popupType();
64+
const hasAutocompletePopup = !!this.inputs.popup() && popupType !== 'dialog';
6465
const hasInlineSuggestion = !!this.inlineSuggestion();
65-
if (hasPopup && hasInlineSuggestion) {
66+
if (hasAutocompletePopup && hasInlineSuggestion) {
6667
return 'both';
6768
}
68-
if (hasPopup) {
69+
if (hasAutocompletePopup) {
6970
return 'list';
7071
}
7172
if (hasInlineSuggestion) {
@@ -143,9 +144,9 @@ export class SimpleComboboxPattern {
143144
return manager;
144145
});
145146

146-
/** The pointerdown event manager for the combobox. */
147-
pointerdown = computed(() => {
148-
const manager = new PointerEventManager();
147+
/** The click event manager for the combobox. */
148+
click = computed(() => {
149+
const manager = new ClickEventManager<PointerEvent>();
149150

150151
if (this.isEditable()) return manager;
151152

@@ -157,12 +158,6 @@ export class SimpleComboboxPattern {
157158
constructor(readonly inputs: SimpleComboboxInputs) {
158159
this.expanded = inputs.expanded;
159160
this.value = inputs.value;
160-
161-
afterRenderEffect(() => {
162-
if (this.inputs.alwaysExpanded()) {
163-
this.expanded.set(true);
164-
}
165-
});
166161
}
167162

168163
/** Handles keydown events for the combobox. */
@@ -172,10 +167,10 @@ export class SimpleComboboxPattern {
172167
}
173168
}
174169

175-
/** Handles pointerdown events for the combobox. */
176-
onPointerdown(event: PointerEvent) {
170+
/** Handles click events for the combobox. */
171+
onClick(event: PointerEvent) {
177172
if (!this.disabled()) {
178-
this.pointerdown().handle(event);
173+
this.click().handle(event);
179174
}
180175
}
181176

@@ -186,9 +181,6 @@ export class SimpleComboboxPattern {
186181

187182
/** Handles focus out events for the combobox. */
188183
onFocusout(event: FocusEvent) {
189-
const focusTarget = event.relatedTarget as Element | null;
190-
if (this.element().contains(focusTarget)) return;
191-
192184
this.isFocused.set(false);
193185
}
194186

@@ -209,7 +201,7 @@ export class SimpleComboboxPattern {
209201

210202
const isDeleting = untracked(() => this.isDeleting());
211203
const isFocused = untracked(() => this.isFocused());
212-
const isExpanded = untracked(() => this.expanded());
204+
const isExpanded = this.expanded();
213205

214206
if (!inlineSuggestion || !isFocused || !isExpanded || isDeleting) return;
215207

0 commit comments

Comments
 (0)