Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/aria/private/simple-combobox/simple-combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions src/aria/simple-combobox/simple-combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

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

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'});
Expand All @@ -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 {
Expand All @@ -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'}]},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -52,16 +52,13 @@ export class SimpleComboboxTreeHighlightExample {
selectedValues = signal<string[]>([]);
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'});
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -110,23 +123,40 @@ 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};
});

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'}]},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

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

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'});
Expand All @@ -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 {
Expand All @@ -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() {
Expand All @@ -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'}]},
Expand Down
Loading