diff --git a/src/aria/private/simple-combobox/simple-combobox.ts b/src/aria/private/simple-combobox/simple-combobox.ts index 32bc23ab728e..9e621bfb9ab5 100644 --- a/src/aria/private/simple-combobox/simple-combobox.ts +++ b/src/aria/private/simple-combobox/simple-combobox.ts @@ -226,6 +226,9 @@ export class SimpleComboboxPattern { const event = this.keyboardEventRelay(); if (event === undefined) return; + // Reset isDeleting when the user navigates, so that the highlight effect can run again. + this.isDeleting.set(false); + const popup = untracked(() => this.inputs.popup()); const popupExpanded = untracked(() => this.isExpanded()); if (popupExpanded) { diff --git a/src/aria/simple-combobox/simple-combobox.spec.ts b/src/aria/simple-combobox/simple-combobox.spec.ts index 25ca62f83bf9..43f71ecfc233 100644 --- a/src/aria/simple-combobox/simple-combobox.spec.ts +++ b/src/aria/simple-combobox/simple-combobox.spec.ts @@ -451,6 +451,35 @@ describe('Combobox', () => { expect(inputElement.value).toBe('California'); expect(fixture.componentInstance.value()).toEqual(['California']); }); + + it('should resume inserting completion strings on navigation after a backspace deletion', async () => { + down(); // Open popup + + // 1. Type 'A', completion should pop up 'Alabama' + input('A'); + expect(inputElement.value).toBe('Alabama'); + + // 2. Simulate Backspace deletion (dispatch InputEvent with deleteContentBackward) + inputElement.value = ''; + inputElement.dispatchEvent( + new InputEvent('input', { + bubbles: true, + inputType: 'deleteContentBackward', + }), + ); + fixture.detectChanges(); + + // Confirm no completion gets inserted during deletion + expect(inputElement.value).toBe(''); + + // 3. Press ArrowDown key to navigate to the next option (Alaska) + down(); + + // Active descendant navigation resets `isDeleting`, so highlight/completion should successfully populate the current active match! + const options = getOptions(); + expect(inputElement.getAttribute('aria-activedescendant')).toBe(options[1].id); + expect(inputElement.value).toBe('Alaska'); + }); }); }); diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts index c362d3aa3d7a..b9c7c0aea1b0 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-auto-select/simple-combobox-tree-auto-select-example.ts @@ -20,9 +20,9 @@ import { import {NgTemplateOutlet} from '@angular/common'; import {OverlayModule} from '@angular/cdk/overlay'; -interface FoodNode { +interface SeasonNode { name: string; - children?: FoodNode[]; + children?: SeasonNode[]; expanded?: boolean; } @@ -50,11 +50,13 @@ export class SimpleComboboxTreeAutoSelectExample { searchString = signal(''); selectedValues = signal([]); - readonly dataSource = signal(FOOD_DATA); + readonly dataSource = signal(SEASON_DATA); constructor() { + afterRenderEffect(() => this._focusAndSelectFirstMatch()); + afterRenderEffect(() => { - const active = this.tree()?._pattern.inputs.activeItem(); + const active = this.tree()?._pattern.activeItem(); if (active) { untracked(() => { active.element()?.scrollIntoView({block: 'nearest'}); @@ -63,19 +65,42 @@ export class SimpleComboboxTreeAutoSelectExample { }); } - filteredGroups = computed(() => { + // Selects the first matching child within the tree filters. + private _focusAndSelectFirstMatch() { + this.filteredGroups(); + + const option = this.firstMatchingOption(); + const treeInstance = this.tree(); + if (option && treeInstance) { + untracked(() => { + const matchedItem = treeInstance._pattern.items().find(item => item.value() === option); + if (matchedItem) { + treeInstance._pattern.treeBehavior.goto(matchedItem, {selectOne: true}); + } + }); + } + } + + filteredData = computed(() => { const search = this.searchString().trim().toLowerCase(); const data = this.dataSource(); if (!search) { - return data; + return {groups: data, firstMatch: undefined}; } - const filterNode = (node: FoodNode): FoodNode | null => { + let firstMatch: string | undefined = undefined; + + const filterNode = (node: SeasonNode): SeasonNode | null => { + // Find the first leaf node that starts with the search string + if (!firstMatch && !node.children && node.name.toLowerCase().startsWith(search)) { + firstMatch = node.name; + } + const matches = node.name.toLowerCase().includes(search); const children = node.children ?.map(child => filterNode(child)) - .filter((child): child is FoodNode => child !== null); + .filter((child): child is SeasonNode => child !== null); if (matches || (children && children.length > 0)) { return { @@ -88,19 +113,42 @@ export class SimpleComboboxTreeAutoSelectExample { return null; }; - return data.map(node => filterNode(node)).filter((node): node is FoodNode => node !== null); + const groups = data + .map(node => filterNode(node)) + .filter((node): node is SeasonNode => node !== null); + return {groups, firstMatch}; }); + filteredGroups = computed(() => this.filteredData().groups); + firstMatchingOption = computed(() => this.filteredData().firstMatch); + onCommit() { - const selected = this.selectedValues(); - if (selected.length > 0) { - this.searchString.set(selected[0]); - this.popupExpanded.set(false); + const treeInstance = this.tree(); + if (!treeInstance) return; + + const activeItem = treeInstance._pattern.activeItem(); + + if (activeItem) { + if (activeItem.selectable()) { + // Selectable child: commit value and close popup. + const selected = this.selectedValues(); + if (selected.length > 0) { + this.searchString.set(selected[0]); + this.popupExpanded.set(false); + } + } else { + // Non-selectable parent: expand and focus its first child. + const children = activeItem.children(); + if (children.length > 0) { + const firstChild = children[0]; + treeInstance._pattern.treeBehavior.goto(firstChild); + } + } } } } -const FOOD_DATA: FoodNode[] = [ +const SEASON_DATA: SeasonNode[] = [ {name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]}, {name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]}, {name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]}, diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts index 4c284b9bebf3..7907131f897e 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree-highlight/simple-combobox-tree-highlight-example.ts @@ -21,9 +21,9 @@ import { import {NgTemplateOutlet} from '@angular/common'; import {OverlayModule} from '@angular/cdk/overlay'; -interface FoodNode { +interface SeasonNode { name: string; - children?: FoodNode[]; + children?: SeasonNode[]; expanded?: boolean; } @@ -52,16 +52,13 @@ export class SimpleComboboxTreeHighlightExample { selectedValues = signal([]); navigated = signal(false); - readonly dataSource = signal(FOOD_DATA); + readonly dataSource = signal(SEASON_DATA); constructor() { - // Highlight mode focus update - afterRenderEffect(() => { - this.filteredGroups(); - }); + afterRenderEffect(() => this._focusAndSelectFirstMatch()); afterRenderEffect(() => { - const active = this.tree()?._pattern.inputs.activeItem(); + const active = this.tree()?._pattern.activeItem(); if (active) { untracked(() => { active.element()?.scrollIntoView({block: 'nearest'}); @@ -76,6 +73,22 @@ export class SimpleComboboxTreeHighlightExample { }); } + // Selects the first matching child within the tree filters. + private _focusAndSelectFirstMatch() { + this.filteredGroups(); + + const option = this.firstMatchingOption(); + const treeInstance = this.tree(); + if (option && treeInstance) { + untracked(() => { + const matchedItem = treeInstance._pattern.items().find(item => item.value() === option); + if (matchedItem) { + treeInstance._pattern.treeBehavior.goto(matchedItem, {selectOne: true}); + } + }); + } + } + filteredData = computed(() => { const search = this.searchString().trim().toLowerCase(); const data = this.dataSource(); @@ -86,7 +99,7 @@ export class SimpleComboboxTreeHighlightExample { let firstMatch: string | undefined = undefined; - const filterNode = (node: FoodNode): FoodNode | null => { + const filterNode = (node: SeasonNode): SeasonNode | null => { // Find the first leaf node that starts with the search string if (!firstMatch && !node.children && node.name.toLowerCase().startsWith(search)) { firstMatch = node.name; @@ -95,7 +108,7 @@ export class SimpleComboboxTreeHighlightExample { const matches = node.name.toLowerCase().includes(search); const children = node.children ?.map(child => filterNode(child)) - .filter((child): child is FoodNode => child !== null); + .filter((child): child is SeasonNode => child !== null); if (matches || (children && children.length > 0)) { return { @@ -110,7 +123,7 @@ export class SimpleComboboxTreeHighlightExample { const groups = data .map(node => filterNode(node)) - .filter((node): node is FoodNode => node !== null); + .filter((node): node is SeasonNode => node !== null); return {groups, firstMatch}; }); @@ -118,15 +131,32 @@ export class SimpleComboboxTreeHighlightExample { firstMatchingOption = computed(() => this.filteredData().firstMatch); onCommit() { - const selected = this.selectedValues(); - if (selected.length > 0) { - this.searchString.set(selected[0]); - this.popupExpanded.set(false); + const treeInstance = this.tree(); + if (!treeInstance) return; + + const activeItem = treeInstance._pattern.activeItem(); + + if (activeItem) { + if (activeItem.selectable()) { + // Selectable child: commit value and close popup. + const selected = this.selectedValues(); + if (selected.length > 0) { + this.searchString.set(selected[0]); + this.popupExpanded.set(false); + } + } else { + // Non-selectable parent: expand and focus its first child. + const children = activeItem.children(); + if (children.length > 0) { + const firstChild = children[0]; + treeInstance._pattern.treeBehavior.goto(firstChild); + } + } } } } -const FOOD_DATA: FoodNode[] = [ +const SEASON_DATA: SeasonNode[] = [ {name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]}, {name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]}, {name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]}, diff --git a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts index c1faeb5bf87c..31bf69019e48 100644 --- a/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts +++ b/src/components-examples/aria/simple-combobox/simple-combobox-tree/simple-combobox-tree-example.ts @@ -12,9 +12,9 @@ import {Component, afterRenderEffect, computed, signal, viewChild, untracked} fr import {NgTemplateOutlet} from '@angular/common'; import {OverlayModule} from '@angular/cdk/overlay'; -interface FoodNode { +interface SeasonNode { name: string; - children?: FoodNode[]; + children?: SeasonNode[]; expanded?: boolean; } @@ -41,11 +41,11 @@ export class SimpleComboboxTreeExample { searchString = signal(''); selectedValues = signal([]); - readonly dataSource = signal(FOOD_DATA); + readonly dataSource = signal(SEASON_DATA); constructor() { afterRenderEffect(() => { - const active = this.tree()?._pattern.inputs.activeItem(); + const active = this.tree()?._pattern.activeItem(); if (active) { untracked(() => { active.element()?.scrollIntoView({block: 'nearest'}); @@ -62,11 +62,11 @@ export class SimpleComboboxTreeExample { return data; } - const filterNode = (node: FoodNode): FoodNode | null => { + const filterNode = (node: SeasonNode): SeasonNode | null => { const matches = node.name.toLowerCase().includes(search); const children = node.children ?.map(child => filterNode(child)) - .filter((child): child is FoodNode => child !== null); + .filter((child): child is SeasonNode => child !== null); if (matches || (children && children.length > 0)) { return { @@ -79,7 +79,7 @@ export class SimpleComboboxTreeExample { return null; }; - return data.map(node => filterNode(node)).filter((node): node is FoodNode => node !== null); + return data.map(node => filterNode(node)).filter((node): node is SeasonNode => node !== null); }); onCommit() { @@ -92,7 +92,7 @@ export class SimpleComboboxTreeExample { } } -const FOOD_DATA: FoodNode[] = [ +const SEASON_DATA: SeasonNode[] = [ {name: 'Winter', children: [{name: 'December'}, {name: 'January'}, {name: 'February'}]}, {name: 'Spring', children: [{name: 'March'}, {name: 'April'}, {name: 'May'}]}, {name: 'Summer', children: [{name: 'June'}, {name: 'July'}, {name: 'August'}]},