From 46f44fabf818029d60dedbf81428b18653aeaba8 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 1 Apr 2025 16:11:45 -0400 Subject: [PATCH 1/3] fix(cdk-experimental/listbox): ignore spaces during typeahead --- .../behaviors/list-typeahead/list-typeahead.ts | 8 ++++---- src/cdk-experimental/ui-patterns/listbox/listbox.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts index aaa2350d6d63..c2544c18a933 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts @@ -37,7 +37,7 @@ export class ListTypeahead { navigation: ListNavigation; /** Keeps track of the characters that typeahead search is being called with. */ - private _query = signal(''); + query = signal(''); /** The index where that the typeahead search was initiated from. */ private _startIndex = signal(undefined); @@ -57,7 +57,7 @@ export class ListTypeahead { } clearTimeout(this.timeout); - this._query.update(q => q + char.toLowerCase()); + this.query.update(q => q + char.toLowerCase()); const item = this._getItem(); if (item) { @@ -65,7 +65,7 @@ export class ListTypeahead { } this.timeout = setTimeout(() => { - this._query.set(''); + this.query.set(''); this._startIndex.set(undefined); }, this.inputs.typeaheadDelay() * 1000); } @@ -88,6 +88,6 @@ export class ListTypeahead { } } - return focusableItems.find(i => i.searchTerm().toLowerCase().startsWith(this._query())); + return focusableItems.find(i => i.searchTerm().toLowerCase().startsWith(this.query())); } } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index bba0f2444ba6..40f0b8a72420 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -91,6 +91,9 @@ export class ListboxPattern { return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; }); + /** Represents the space key. Does nothing when the user is actively using typeahead. */ + spaceKey = computed(() => (this.typeahead.query().length ? '' : ' ')); + /** The regexp used to decide if a key should trigger typeahead. */ typeaheadRegexp = /^.$/; // TODO: Ignore spaces? @@ -127,7 +130,7 @@ export class ListboxPattern { if (this.inputs.multi()) { manager - .on(Modifier.Shift, ' ', () => this._updateSelection({selectFromAnchor: true})) + .on(Modifier.Shift, this.spaceKey, () => this._updateSelection({selectFromAnchor: true})) .on(Modifier.Shift, 'Enter', () => this._updateSelection({selectFromAnchor: true})) .on(Modifier.Shift, this.prevKey, () => this.prev({toggle: true})) .on(Modifier.Shift, this.nextKey, () => this.next({toggle: true})) @@ -137,12 +140,12 @@ export class ListboxPattern { } if (!this.followFocus() && this.inputs.multi()) { - manager.on(' ', () => this._updateSelection({toggle: true})); + manager.on(this.spaceKey, () => this._updateSelection({toggle: true})); manager.on('Enter', () => this._updateSelection({toggle: true})); } if (!this.followFocus() && !this.inputs.multi()) { - manager.on(' ', () => this._updateSelection({toggleOne: true})); + manager.on(this.spaceKey, () => this._updateSelection({toggleOne: true})); manager.on('Enter', () => this._updateSelection({toggleOne: true})); } From 32d8271d5a6410202ddbede6358122bef0752a3f Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 11 Apr 2025 11:28:13 -0400 Subject: [PATCH 2/3] fixup! fix(cdk-experimental/listbox): ignore spaces during typeahead --- .../list-typeahead/list-typeahead.spec.ts | 123 ++++++------------ .../list-typeahead/list-typeahead.ts | 16 ++- .../ui-patterns/listbox/listbox.ts | 12 +- 3 files changed, 59 insertions(+), 92 deletions(-) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts index 1be9ab8d0745..4f82a8f15027 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.spec.ts @@ -27,117 +27,65 @@ describe('List Typeahead', () => { ); } + let items: SignalLike; + let typeahead: ListTypeahead; + let navigation: ListNavigation; + + beforeEach(() => { + items = getItems(5); + navigation = new ListNavigation({ + items, + wrap: signal(false), + activeIndex: signal(0), + skipDisabled: signal(false), + textDirection: signal('ltr'), + orientation: signal('vertical'), + }); + typeahead = new ListTypeahead({ + navigation, + typeaheadDelay: signal(0.5), + }); + }); + describe('#search', () => { it('should navigate to an item', () => { - const items = getItems(5); - const activeIndex = signal(0); - const navigation = new ListNavigation({ - items, - activeIndex, - wrap: signal(false), - skipDisabled: signal(false), - textDirection: signal('ltr'), - orientation: signal('vertical'), - }); - const typeahead = new ListTypeahead({ - navigation, - typeaheadDelay: signal(0.5), - }); - typeahead.search('i'); - expect(activeIndex()).toBe(1); + expect(navigation.inputs.activeIndex()).toBe(1); typeahead.search('t'); typeahead.search('e'); typeahead.search('m'); typeahead.search(' '); typeahead.search('3'); - expect(activeIndex()).toBe(3); + expect(navigation.inputs.activeIndex()).toBe(3); }); it('should reset after a delay', fakeAsync(() => { - const items = getItems(5); - const activeIndex = signal(0); - const navigation = new ListNavigation({ - items, - activeIndex, - wrap: signal(false), - skipDisabled: signal(false), - textDirection: signal('ltr'), - orientation: signal('vertical'), - }); - const typeahead = new ListTypeahead({ - navigation, - typeaheadDelay: signal(0.5), - }); - typeahead.search('i'); - expect(activeIndex()).toBe(1); + expect(navigation.inputs.activeIndex()).toBe(1); tick(500); typeahead.search('i'); - expect(activeIndex()).toBe(2); + expect(navigation.inputs.activeIndex()).toBe(2); })); it('should skip disabled items', () => { - const items = getItems(5); - const activeIndex = signal(0); - const navigation = new ListNavigation({ - items, - activeIndex, - wrap: signal(false), - skipDisabled: signal(true), - textDirection: signal('ltr'), - orientation: signal('vertical'), - }); - const typeahead = new ListTypeahead({ - navigation, - typeaheadDelay: signal(0.5), - }); items()[1].disabled.set(true); - + (navigation.inputs.skipDisabled as WritableSignalLike).set(true); typeahead.search('i'); - expect(activeIndex()).toBe(2); + console.log(typeahead.inputs.navigation.inputs.items().map(i => i.disabled())); + expect(navigation.inputs.activeIndex()).toBe(2); }); it('should not skip disabled items', () => { - const items = getItems(5); - const activeIndex = signal(0); - const navigation = new ListNavigation({ - items, - activeIndex, - wrap: signal(false), - skipDisabled: signal(false), - textDirection: signal('ltr'), - orientation: signal('vertical'), - }); - const typeahead = new ListTypeahead({ - navigation, - typeaheadDelay: signal(0.5), - }); items()[1].disabled.set(true); - + (navigation.inputs.skipDisabled as WritableSignalLike).set(false); typeahead.search('i'); - expect(activeIndex()).toBe(1); + expect(navigation.inputs.activeIndex()).toBe(1); }); it('should ignore keys like shift', () => { - const items = getItems(5); - const activeIndex = signal(0); - const navigation = new ListNavigation({ - items, - activeIndex, - wrap: signal(false), - skipDisabled: signal(false), - textDirection: signal('ltr'), - orientation: signal('vertical'), - }); - const typeahead = new ListTypeahead({ - navigation, - typeaheadDelay: signal(0.5), - }); - typeahead.search('i'); typeahead.search('t'); typeahead.search('e'); @@ -147,7 +95,18 @@ describe('List Typeahead', () => { typeahead.search('m'); typeahead.search(' '); typeahead.search('2'); - expect(activeIndex()).toBe(2); + expect(navigation.inputs.activeIndex()).toBe(2); + }); + + it('should not allow a query to begin with a space', () => { + typeahead.search(' '); + typeahead.search('i'); + typeahead.search('t'); + typeahead.search('e'); + typeahead.search('m'); + typeahead.search(' '); + typeahead.search('3'); + expect(navigation.inputs.activeIndex()).toBe(3); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts index c2544c18a933..2967d27cf09f 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {signal} from '@angular/core'; +import {computed, signal} from '@angular/core'; import {SignalLike} from '../signal-like/signal-like'; import {ListNavigationItem, ListNavigation} from '../list-navigation/list-navigation'; @@ -36,8 +36,10 @@ export class ListTypeahead { /** The navigation controller of the parent list. */ navigation: ListNavigation; + isTyping = computed(() => this._query().length > 0); + /** Keeps track of the characters that typeahead search is being called with. */ - query = signal(''); + private _query = signal(''); /** The index where that the typeahead search was initiated from. */ private _startIndex = signal(undefined); @@ -52,12 +54,16 @@ export class ListTypeahead { return; } + if (!this.isTyping() && char === ' ') { + return; + } + if (this._startIndex() === undefined) { this._startIndex.set(this.navigation.inputs.activeIndex()); } clearTimeout(this.timeout); - this.query.update(q => q + char.toLowerCase()); + this._query.update(q => q + char.toLowerCase()); const item = this._getItem(); if (item) { @@ -65,7 +71,7 @@ export class ListTypeahead { } this.timeout = setTimeout(() => { - this.query.set(''); + this._query.set(''); this._startIndex.set(undefined); }, this.inputs.typeaheadDelay() * 1000); } @@ -88,6 +94,6 @@ export class ListTypeahead { } } - return focusableItems.find(i => i.searchTerm().toLowerCase().startsWith(this.query())); + return focusableItems.find(i => i.searchTerm().toLowerCase().startsWith(this._query())); } } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 40f0b8a72420..1e9425248980 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -92,7 +92,7 @@ export class ListboxPattern { }); /** Represents the space key. Does nothing when the user is actively using typeahead. */ - spaceKey = computed(() => (this.typeahead.query().length ? '' : ' ')); + dynamicSpaceKey = computed(() => (this.typeahead.isTyping() ? '' : ' ')); /** The regexp used to decide if a key should trigger typeahead. */ typeaheadRegexp = /^.$/; // TODO: Ignore spaces? @@ -130,22 +130,24 @@ export class ListboxPattern { if (this.inputs.multi()) { manager - .on(Modifier.Shift, this.spaceKey, () => this._updateSelection({selectFromAnchor: true})) .on(Modifier.Shift, 'Enter', () => this._updateSelection({selectFromAnchor: true})) .on(Modifier.Shift, this.prevKey, () => this.prev({toggle: true})) .on(Modifier.Shift, this.nextKey, () => this.next({toggle: true})) .on(Modifier.Ctrl | Modifier.Shift, 'Home', () => this.first({selectFromActive: true})) .on(Modifier.Ctrl | Modifier.Shift, 'End', () => this.last({selectFromActive: true})) - .on(Modifier.Ctrl, 'A', () => this._updateSelection({selectAll: true})); + .on(Modifier.Ctrl, 'A', () => this._updateSelection({selectAll: true})) + .on(Modifier.Shift, this.dynamicSpaceKey, () => + this._updateSelection({selectFromAnchor: true}), + ); } if (!this.followFocus() && this.inputs.multi()) { - manager.on(this.spaceKey, () => this._updateSelection({toggle: true})); + manager.on(this.dynamicSpaceKey, () => this._updateSelection({toggle: true})); manager.on('Enter', () => this._updateSelection({toggle: true})); } if (!this.followFocus() && !this.inputs.multi()) { - manager.on(this.spaceKey, () => this._updateSelection({toggleOne: true})); + manager.on(this.dynamicSpaceKey, () => this._updateSelection({toggleOne: true})); manager.on('Enter', () => this._updateSelection({toggleOne: true})); } From bc750a3acf9346d2459b4e2ec306ee796f703238 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Fri, 11 Apr 2025 11:29:18 -0400 Subject: [PATCH 3/3] fixup! fix(cdk-experimental/listbox): ignore spaces during typeahead --- .../ui-patterns/behaviors/list-typeahead/list-typeahead.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts index 2967d27cf09f..36d3a65898b3 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/list-typeahead.ts @@ -36,6 +36,7 @@ export class ListTypeahead { /** The navigation controller of the parent list. */ navigation: ListNavigation; + /** Whether the user is actively typing a typeahead search query. */ isTyping = computed(() => this._query().length > 0); /** Keeps track of the characters that typeahead search is being called with. */