Skip to content

Commit 3f631c8

Browse files
authored
refactor(aria/combobox): Updates the combobox-tree-examples and resets isDeleting to false with keypress (#33193)
1 parent d396fa8 commit 3f631c8

5 files changed

Lines changed: 148 additions & 38 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,9 @@ export class SimpleComboboxPattern {
226226
const event = this.keyboardEventRelay();
227227
if (event === undefined) return;
228228

229+
// Reset isDeleting when the user navigates, so that the highlight effect can run again.
230+
this.isDeleting.set(false);
231+
229232
const popup = untracked(() => this.inputs.popup());
230233
const popupExpanded = untracked(() => this.isExpanded());
231234
if (popupExpanded) {

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,35 @@ describe('Combobox', () => {
451451
expect(inputElement.value).toBe('California');
452452
expect(fixture.componentInstance.value()).toEqual(['California']);
453453
});
454+
455+
it('should resume inserting completion strings on navigation after a backspace deletion', async () => {
456+
down(); // Open popup
457+
458+
// 1. Type 'A', completion should pop up 'Alabama'
459+
input('A');
460+
expect(inputElement.value).toBe('Alabama');
461+
462+
// 2. Simulate Backspace deletion (dispatch InputEvent with deleteContentBackward)
463+
inputElement.value = '';
464+
inputElement.dispatchEvent(
465+
new InputEvent('input', {
466+
bubbles: true,
467+
inputType: 'deleteContentBackward',
468+
}),
469+
);
470+
fixture.detectChanges();
471+
472+
// Confirm no completion gets inserted during deletion
473+
expect(inputElement.value).toBe('');
474+
475+
// 3. Press ArrowDown key to navigate to the next option (Alaska)
476+
down();
477+
478+
// Active descendant navigation resets `isDeleting`, so highlight/completion should successfully populate the current active match!
479+
const options = getOptions();
480+
expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id);
481+
expect(inputElement.value).toBe('Alaska');
482+
});
454483
});
455484
});
456485

src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ import {
2020
import {NgTemplateOutlet} from '@angular/common';
2121
import {OverlayModule} from '@angular/cdk/overlay';
2222

23-
interface FoodNode {
23+
interface SeasonNode {
2424
name: string;
25-
children?: FoodNode[];
25+
children?: SeasonNode[];
2626
expanded?: boolean;
2727
}
2828

@@ -50,11 +50,13 @@ export class SimpleComboboxTreeAutoSelectExample {
5050
searchString = signal('');
5151
selectedValues = signal<string[]>([]);
5252

53-
readonly dataSource = signal(FOOD_DATA);
53+
readonly dataSource = signal(SEASON_DATA);
5454

5555
constructor() {
56+
afterRenderEffect(() => this._focusAndSelectFirstMatch());
57+
5658
afterRenderEffect(() => {
57-
const active = this.tree()?._pattern.inputs.activeItem();
59+
const active = this.tree()?._pattern.activeItem();
5860
if (active) {
5961
untracked(() => {
6062
active.element()?.scrollIntoView({block: 'nearest'});
@@ -63,19 +65,42 @@ export class SimpleComboboxTreeAutoSelectExample {
6365
});
6466
}
6567

66-
filteredGroups = computed(() => {
68+
// Selects the first matching child within the tree filters.
69+
private _focusAndSelectFirstMatch() {
70+
this.filteredGroups();
71+
72+
const option = this.firstMatchingOption();
73+
const treeInstance = this.tree();
74+
if (option && treeInstance) {
75+
untracked(() => {
76+
const matchedItem = treeInstance._pattern.items().find(item => item.value() === option);
77+
if (matchedItem) {
78+
treeInstance._pattern.treeBehavior.goto(matchedItem, {selectOne: true});
79+
}
80+
});
81+
}
82+
}
83+
84+
filteredData = computed(() => {
6785
const search = this.searchString().trim().toLowerCase();
6886
const data = this.dataSource();
6987

7088
if (!search) {
71-
return data;
89+
return {groups: data, firstMatch: undefined};
7290
}
7391

74-
const filterNode = (node: FoodNode): FoodNode | null => {
92+
let firstMatch: string | undefined = undefined;
93+
94+
const filterNode = (node: SeasonNode): SeasonNode | null => {
95+
// Find the first leaf node that starts with the search string
96+
if (!firstMatch && !node.children && node.name.toLowerCase().startsWith(search)) {
97+
firstMatch = node.name;
98+
}
99+
75100
const matches = node.name.toLowerCase().includes(search);
76101
const children = node.children
77102
?.map(child => filterNode(child))
78-
.filter((child): child is FoodNode => child !== null);
103+
.filter((child): child is SeasonNode => child !== null);
79104

80105
if (matches || (children && children.length > 0)) {
81106
return {
@@ -88,19 +113,42 @@ export class SimpleComboboxTreeAutoSelectExample {
88113
return null;
89114
};
90115

91-
return data.map(node => filterNode(node)).filter((node): node is FoodNode => node !== null);
116+
const groups = data
117+
.map(node => filterNode(node))
118+
.filter((node): node is SeasonNode => node !== null);
119+
return {groups, firstMatch};
92120
});
93121

122+
filteredGroups = computed(() => this.filteredData().groups);
123+
firstMatchingOption = computed(() => this.filteredData().firstMatch);
124+
94125
onCommit() {
95-
const selected = this.selectedValues();
96-
if (selected.length > 0) {
97-
this.searchString.set(selected[0]);
98-
this.popupExpanded.set(false);
126+
const treeInstance = this.tree();
127+
if (!treeInstance) return;
128+
129+
const activeItem = treeInstance._pattern.activeItem();
130+
131+
if (activeItem) {
132+
if (activeItem.selectable()) {
133+
// Selectable child: commit value and close popup.
134+
const selected = this.selectedValues();
135+
if (selected.length > 0) {
136+
this.searchString.set(selected[0]);
137+
this.popupExpanded.set(false);
138+
}
139+
} else {
140+
// Non-selectable parent: expand and focus its first child.
141+
const children = activeItem.children();
142+
if (children.length > 0) {
143+
const firstChild = children[0];
144+
treeInstance._pattern.treeBehavior.goto(firstChild);
145+
}
146+
}
99147
}
100148
}
101149
}
102150

103-
const FOOD_DATA: FoodNode[] = [
151+
const SEASON_DATA: SeasonNode[] = [
104152
{name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]},
105153
{name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]},
106154
{name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]},

src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ import {
2121
import {NgTemplateOutlet} from '@angular/common';
2222
import {OverlayModule} from '@angular/cdk/overlay';
2323

24-
interface FoodNode {
24+
interface SeasonNode {
2525
name: string;
26-
children?: FoodNode[];
26+
children?: SeasonNode[];
2727
expanded?: boolean;
2828
}
2929

@@ -52,16 +52,13 @@ export class SimpleComboboxTreeHighlightExample {
5252
selectedValues = signal<string[]>([]);
5353
navigated = signal(false);
5454

55-
readonly dataSource = signal(FOOD_DATA);
55+
readonly dataSource = signal(SEASON_DATA);
5656

5757
constructor() {
58-
// Highlight mode focus update
59-
afterRenderEffect(() => {
60-
this.filteredGroups();
61-
});
58+
afterRenderEffect(() => this._focusAndSelectFirstMatch());
6259

6360
afterRenderEffect(() => {
64-
const active = this.tree()?._pattern.inputs.activeItem();
61+
const active = this.tree()?._pattern.activeItem();
6562
if (active) {
6663
untracked(() => {
6764
active.element()?.scrollIntoView({block: 'nearest'});
@@ -76,6 +73,22 @@ export class SimpleComboboxTreeHighlightExample {
7673
});
7774
}
7875

76+
// Selects the first matching child within the tree filters.
77+
private _focusAndSelectFirstMatch() {
78+
this.filteredGroups();
79+
80+
const option = this.firstMatchingOption();
81+
const treeInstance = this.tree();
82+
if (option && treeInstance) {
83+
untracked(() => {
84+
const matchedItem = treeInstance._pattern.items().find(item => item.value() === option);
85+
if (matchedItem) {
86+
treeInstance._pattern.treeBehavior.goto(matchedItem, {selectOne: true});
87+
}
88+
});
89+
}
90+
}
91+
7992
filteredData = computed(() => {
8093
const search = this.searchString().trim().toLowerCase();
8194
const data = this.dataSource();
@@ -86,7 +99,7 @@ export class SimpleComboboxTreeHighlightExample {
8699

87100
let firstMatch: string | undefined = undefined;
88101

89-
const filterNode = (node: FoodNode): FoodNode | null => {
102+
const filterNode = (node: SeasonNode): SeasonNode | null => {
90103
// Find the first leaf node that starts with the search string
91104
if (!firstMatch && !node.children && node.name.toLowerCase().startsWith(search)) {
92105
firstMatch = node.name;
@@ -95,7 +108,7 @@ export class SimpleComboboxTreeHighlightExample {
95108
const matches = node.name.toLowerCase().includes(search);
96109
const children = node.children
97110
?.map(child => filterNode(child))
98-
.filter((child): child is FoodNode => child !== null);
111+
.filter((child): child is SeasonNode => child !== null);
99112

100113
if (matches || (children && children.length > 0)) {
101114
return {
@@ -110,23 +123,40 @@ export class SimpleComboboxTreeHighlightExample {
110123

111124
const groups = data
112125
.map(node => filterNode(node))
113-
.filter((node): node is FoodNode => node !== null);
126+
.filter((node): node is SeasonNode => node !== null);
114127
return {groups, firstMatch};
115128
});
116129

117130
filteredGroups = computed(() => this.filteredData().groups);
118131
firstMatchingOption = computed(() => this.filteredData().firstMatch);
119132

120133
onCommit() {
121-
const selected = this.selectedValues();
122-
if (selected.length > 0) {
123-
this.searchString.set(selected[0]);
124-
this.popupExpanded.set(false);
134+
const treeInstance = this.tree();
135+
if (!treeInstance) return;
136+
137+
const activeItem = treeInstance._pattern.activeItem();
138+
139+
if (activeItem) {
140+
if (activeItem.selectable()) {
141+
// Selectable child: commit value and close popup.
142+
const selected = this.selectedValues();
143+
if (selected.length > 0) {
144+
this.searchString.set(selected[0]);
145+
this.popupExpanded.set(false);
146+
}
147+
} else {
148+
// Non-selectable parent: expand and focus its first child.
149+
const children = activeItem.children();
150+
if (children.length > 0) {
151+
const firstChild = children[0];
152+
treeInstance._pattern.treeBehavior.goto(firstChild);
153+
}
154+
}
125155
}
126156
}
127157
}
128158

129-
const FOOD_DATA: FoodNode[] = [
159+
const SEASON_DATA: SeasonNode[] = [
130160
{name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]},
131161
{name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]},
132162
{name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]},

src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import {Component, afterRenderEffect, computed, signal, viewChild, untracked} fr
1212
import {NgTemplateOutlet} from '@angular/common';
1313
import {OverlayModule} from '@angular/cdk/overlay';
1414

15-
interface FoodNode {
15+
interface SeasonNode {
1616
name: string;
17-
children?: FoodNode[];
17+
children?: SeasonNode[];
1818
expanded?: boolean;
1919
}
2020

@@ -41,11 +41,11 @@ export class SimpleComboboxTreeExample {
4141
searchString = signal('');
4242
selectedValues = signal<string[]>([]);
4343

44-
readonly dataSource = signal(FOOD_DATA);
44+
readonly dataSource = signal(SEASON_DATA);
4545

4646
constructor() {
4747
afterRenderEffect(() => {
48-
const active = this.tree()?._pattern.inputs.activeItem();
48+
const active = this.tree()?._pattern.activeItem();
4949
if (active) {
5050
untracked(() => {
5151
active.element()?.scrollIntoView({block: 'nearest'});
@@ -62,11 +62,11 @@ export class SimpleComboboxTreeExample {
6262
return data;
6363
}
6464

65-
const filterNode = (node: FoodNode): FoodNode | null => {
65+
const filterNode = (node: SeasonNode): SeasonNode | null => {
6666
const matches = node.name.toLowerCase().includes(search);
6767
const children = node.children
6868
?.map(child => filterNode(child))
69-
.filter((child): child is FoodNode => child !== null);
69+
.filter((child): child is SeasonNode => child !== null);
7070

7171
if (matches || (children && children.length > 0)) {
7272
return {
@@ -79,7 +79,7 @@ export class SimpleComboboxTreeExample {
7979
return null;
8080
};
8181

82-
return data.map(node => filterNode(node)).filter((node): node is FoodNode => node !== null);
82+
return data.map(node => filterNode(node)).filter((node): node is SeasonNode => node !== null);
8383
});
8484

8585
onCommit() {
@@ -92,7 +92,7 @@ export class SimpleComboboxTreeExample {
9292
}
9393
}
9494

95-
const FOOD_DATA: FoodNode[] = [
95+
const SEASON_DATA: SeasonNode[] = [
9696
{name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]},
9797
{name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]},
9898
{name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]},

0 commit comments

Comments
 (0)