From e6735a7e3bc91a8e2d2746632602d4237357e494 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 22 Apr 2025 11:28:40 -0400 Subject: [PATCH 1/2] refactor(cdk-experimental/ui-patterns): move active index operations into list-focus --- .../behaviors/list-focus/BUILD.bazel | 4 +- .../behaviors/list-focus/list-focus.spec.ts | 211 ++++++------- .../behaviors/list-focus/list-focus.ts | 75 +++-- .../behaviors/list-navigation/BUILD.bazel | 3 +- .../list-navigation/list-navigation.spec.ts | 249 ++++++--------- .../list-navigation/list-navigation.ts | 47 +-- .../behaviors/list-selection/BUILD.bazel | 5 +- .../list-selection/list-selection.spec.ts | 295 +++++++----------- .../list-selection/list-selection.ts | 41 +-- .../behaviors/list-typeahead/BUILD.bazel | 5 +- .../list-typeahead/list-typeahead.spec.ts | 95 +++--- .../list-typeahead/list-typeahead.ts | 26 +- .../ui-patterns/listbox/listbox.ts | 14 +- src/cdk-experimental/ui-patterns/tabs/tabs.ts | 11 +- 14 files changed, 460 insertions(+), 621 deletions(-) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel index 1567f3e78fab..ccfd64500c97 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/BUILD.bazel @@ -10,7 +10,7 @@ ts_project( exclude = ["**/*.spec.ts"], ), deps = [ - "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//:node_modules/@angular/core", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) @@ -22,8 +22,6 @@ ts_project( deps = [ ":list-focus", "//:node_modules/@angular/core", - "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", - "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts index 79c065e78ccd..1e0f0eedb9aa 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts @@ -6,152 +6,127 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, signal} from '@angular/core'; -import {SignalLike} from '../signal-like/signal-like'; -import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation'; +import {Signal, signal, WritableSignal} from '@angular/core'; import {ListFocus, ListFocusInputs, ListFocusItem} from './list-focus'; -describe('List Focus', () => { - interface TestItem extends ListFocusItem { - tabindex: SignalLike<-1 | 0>; - } - - function getItems(length: number): SignalLike { - return signal( - Array.from({length}).map((_, i) => ({ - index: signal(i), +type TestItem = ListFocusItem & { + disabled: WritableSignal; +}; + +type TestInputs = Partial> & { + numItems?: number; +}; + +export function getListFocus(inputs: TestInputs = {}): ListFocus { + return new ListFocus({ + activeIndex: signal(0), + disabled: signal(false), + skipDisabled: signal(false), + focusMode: signal('roving'), + items: getItems(inputs.numItems ?? 5), + ...inputs, + }); +} + +function getItems(length: number): Signal { + return signal( + Array.from({length}).map((_, i) => { + return { id: signal(`${i}`), - tabindex: signal(-1), disabled: signal(false), element: signal({focus: () => {}} as HTMLElement), - })), - ); - } - - function getNavigation( - items: SignalLike, - args: Partial> = {}, - ): ListNavigation { - return new ListNavigation({ - items, - wrap: signal(false), - activeIndex: signal(0), - skipDisabled: signal(false), - textDirection: signal('ltr'), - orientation: signal('vertical'), - ...args, - }); - } - - function getFocus( - navigation: ListNavigation, - args: Partial> = {}, - ): ListFocus { - return new ListFocus({ - navigation, - focusMode: signal('roving'), - ...args, - }); - } + }; + }), + ); +} +describe('List Focus', () => { describe('roving', () => { + let focusManager: ListFocus; + + beforeEach(() => { + focusManager = getListFocus({focusMode: signal('roving')}); + }); + it('should set the list tabindex to -1', () => { - const items = getItems(5); - const nav = getNavigation(items); - const focus = getFocus(nav); - const tabindex = computed(() => focus.getListTabindex()); - expect(tabindex()).toBe(-1); + expect(focusManager.getListTabindex()).toBe(-1); }); it('should set the activedescendant to undefined', () => { - const items = getItems(5); - const nav = getNavigation(items); - const focus = getFocus(nav); - expect(focus.getActiveDescendant()).toBeUndefined(); + expect(focusManager.getActiveDescendant()).toBeUndefined(); }); - it('should set the first items tabindex to 0', () => { - const items = getItems(5); - const nav = getNavigation(items); - const focus = getFocus(nav); - - items().forEach(i => { - i.tabindex = computed(() => focus.getItemTabindex(i)); - }); - - expect(items()[0].tabindex()).toBe(0); - expect(items()[1].tabindex()).toBe(-1); - expect(items()[2].tabindex()).toBe(-1); - expect(items()[3].tabindex()).toBe(-1); - expect(items()[4].tabindex()).toBe(-1); + it('should set the tabindex based on the active index', () => { + const items = focusManager.inputs.items() as TestItem[]; + focusManager.inputs.activeIndex.set(2); + expect(focusManager.getItemTabindex(items[0])).toBe(-1); + expect(focusManager.getItemTabindex(items[1])).toBe(-1); + expect(focusManager.getItemTabindex(items[2])).toBe(0); + expect(focusManager.getItemTabindex(items[3])).toBe(-1); + expect(focusManager.getItemTabindex(items[4])).toBe(-1); }); + }); - it('should update the tabindex of the active item when navigating', () => { - const items = getItems(5); - const nav = getNavigation(items); - const focus = getFocus(nav); - - items().forEach(i => { - i.tabindex = computed(() => focus.getItemTabindex(i)); - }); - - nav.next(); + describe('activedescendant', () => { + let focusManager: ListFocus; - expect(items()[0].tabindex()).toBe(-1); - expect(items()[1].tabindex()).toBe(0); - expect(items()[2].tabindex()).toBe(-1); - expect(items()[3].tabindex()).toBe(-1); - expect(items()[4].tabindex()).toBe(-1); + beforeEach(() => { + focusManager = getListFocus({focusMode: signal('activedescendant')}); }); - }); - describe('activedescendant', () => { it('should set the list tabindex to 0', () => { - const items = getItems(5); - const nav = getNavigation(items); - const focus = getFocus(nav, { - focusMode: signal('activedescendant'), - }); - const tabindex = computed(() => focus.getListTabindex()); - expect(tabindex()).toBe(0); + expect(focusManager.getListTabindex()).toBe(0); }); it('should set the activedescendant to the active items id', () => { - const items = getItems(5); - const nav = getNavigation(items); - const focus = getFocus(nav, { - focusMode: signal('activedescendant'), - }); - expect(focus.getActiveDescendant()).toBe(items()[0].id()); + expect(focusManager.getActiveDescendant()).toBe(focusManager.inputs.items()[0].id()); }); it('should set the tabindex of all items to -1', () => { - const items = getItems(5); - const nav = getNavigation(items); - const focus = getFocus(nav, { - focusMode: signal('activedescendant'), - }); - - items().forEach(i => { - i.tabindex = computed(() => focus.getItemTabindex(i)); - }); - - expect(items()[0].tabindex()).toBe(-1); - expect(items()[1].tabindex()).toBe(-1); - expect(items()[2].tabindex()).toBe(-1); - expect(items()[3].tabindex()).toBe(-1); - expect(items()[4].tabindex()).toBe(-1); + const items = focusManager.inputs.items() as TestItem[]; + focusManager.inputs.activeIndex.set(0); + expect(focusManager.getItemTabindex(items[0])).toBe(-1); + expect(focusManager.getItemTabindex(items[1])).toBe(-1); + expect(focusManager.getItemTabindex(items[2])).toBe(-1); + expect(focusManager.getItemTabindex(items[3])).toBe(-1); + expect(focusManager.getItemTabindex(items[4])).toBe(-1); }); it('should update the activedescendant of the list when navigating', () => { - const items = getItems(5); - const nav = getNavigation(items); - const focus = getFocus(nav, { - focusMode: signal('activedescendant'), - }); - - nav.next(); - expect(focus.getActiveDescendant()).toBe(items()[1].id()); + focusManager.inputs.activeIndex.set(1); + expect(focusManager.getActiveDescendant()).toBe(focusManager.inputs.items()[1].id()); }); }); + + describe('#isFocusable', () => { + it('should return true for enabled items', () => { + const focusManager = getListFocus({skipDisabled: signal(true)}); + const items = focusManager.inputs.items() as TestItem[]; + expect(focusManager.isFocusable(items[0])).toBeTrue(); + expect(focusManager.isFocusable(items[1])).toBeTrue(); + expect(focusManager.isFocusable(items[2])).toBeTrue(); + }); + + it('should return false for disabled items', () => { + const focusManager = getListFocus({skipDisabled: signal(true)}); + const items = focusManager.inputs.items() as TestItem[]; + items[1].disabled.set(true); + + expect(focusManager.isFocusable(items[0])).toBeTrue(); + expect(focusManager.isFocusable(items[1])).toBeFalse(); + expect(focusManager.isFocusable(items[2])).toBeTrue(); + }); + + it('should return true for disabled items if skip disabled is false', () => { + const focusManager = getListFocus({skipDisabled: signal(false)}); + const items = focusManager.inputs.items() as TestItem[]; + items[1].disabled.set(true); + + expect(focusManager.isFocusable(items[0])).toBeTrue(); + expect(focusManager.isFocusable(items[1])).toBeTrue(); + expect(focusManager.isFocusable(items[2])).toBeTrue(); + }); + }); + + describe('#focus', () => {}); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts index 3600a4c9e2b4..cd6ffec292dc 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -6,65 +6,104 @@ * found in the LICENSE file at https://angular.dev/license */ -import {SignalLike} from '../signal-like/signal-like'; -import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; +import {computed, signal} from '@angular/core'; +import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; /** Represents an item in a collection, such as a listbox option, than may receive focus. */ -export interface ListFocusItem extends ListNavigationItem { +export interface ListFocusItem { /** A unique identifier for the item. */ id: SignalLike; /** The html element that should receive focus. */ element: SignalLike; + + /** Whether an item is disabled. */ + disabled: SignalLike; } /** Represents the required inputs for a collection that contains focusable items. */ export interface ListFocusInputs { /** The focus strategy used by the list. */ focusMode: SignalLike<'roving' | 'activedescendant'>; + + /** Whether the list is disabled. */ + disabled: SignalLike; + + /** The items in the list. */ + items: SignalLike; + + /** The index of the current active item. */ + activeIndex: WritableSignalLike; + + /** Whether disabled items in the list should be skipped when navigating. */ + skipDisabled: SignalLike; } /** Controls focus for a list of items. */ export class ListFocus { - /** The navigation controller of the parent list. */ - navigation: ListNavigation; + /** The last index that was active. */ + prevActiveIndex = signal(0); - constructor(readonly inputs: ListFocusInputs & {navigation: ListNavigation}) { - this.navigation = inputs.navigation; + /** The current active item. */ + activeItem = computed(() => this.inputs.items()[this.inputs.activeIndex()]); + + constructor(readonly inputs: ListFocusInputs) {} + + /** Whether the list is in a disabled state. */ + isListDisabled(): boolean { + return this.inputs.disabled() || this.inputs.items().every(i => i.disabled()); } /** The id of the current active item. */ getActiveDescendant(): string | undefined { - if (this.inputs.focusMode() === 'roving') { + if (this.isListDisabled()) { return undefined; } - if (this.navigation.inputs.items().length) { - return this.navigation.inputs.items()[this.navigation.inputs.activeIndex()].id(); + if (this.inputs.focusMode() === 'roving') { + return undefined; } - return undefined; + return this.inputs.items()[this.inputs.activeIndex()].id(); } /** The tabindex for the list. */ getListTabindex(): -1 | 0 { + if (this.isListDisabled()) { + return 0; + } return this.inputs.focusMode() === 'activedescendant' ? 0 : -1; } /** Returns the tabindex for the given item. */ getItemTabindex(item: T): -1 | 0 { + if (this.inputs.disabled()) { + return -1; + } if (this.inputs.focusMode() === 'activedescendant') { return -1; } - const index = this.navigation.inputs.items().indexOf(item); - return this.navigation.inputs.activeIndex() === index ? 0 : -1; + const index = this.inputs.items().indexOf(item); + return this.inputs.activeIndex() === index ? 0 : -1; } /** Focuses the current active item. */ - focus() { - if (this.inputs.focusMode() === 'activedescendant') { - return; + focus(item: T): boolean { + if (this.isListDisabled() || !this.isFocusable(item)) { + return false; + } + + this.prevActiveIndex.set(this.inputs.activeIndex()); + const index = this.inputs.items().indexOf(item); + this.inputs.activeIndex.set(index); + + if (this.inputs.focusMode() === 'roving') { + item.element().focus(); } - const item = this.navigation.inputs.items()[this.navigation.inputs.activeIndex()]; - item.element().focus(); + return true; + } + + /** Returns true if the given item can be navigated to. */ + isFocusable(item: T): boolean { + return !item.disabled() || !this.inputs.skipDisabled(); } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/BUILD.bazel index 7111f782b3bd..deb61ca4f912 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/BUILD.bazel @@ -11,6 +11,7 @@ ts_project( ), deps = [ "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) @@ -22,7 +23,7 @@ ts_project( deps = [ ":list-navigation", "//:node_modules/@angular/core", - "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus:unit_test_sources", ], ) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts index e2234a5784ed..253cb54450b9 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.spec.ts @@ -6,107 +6,91 @@ * found in the LICENSE file at https://angular.dev/license */ -import {signal} from '@angular/core'; -import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; -import {ListNavigationItem, ListNavigation, ListNavigationInputs} from './list-navigation'; +import {signal, WritableSignal} from '@angular/core'; +import {ListNavigation, ListNavigationInputs, ListNavigationItem} from './list-navigation'; +import {getListFocus} from '../list-focus/list-focus.spec'; + +type TestItem = ListNavigationItem & { + disabled: WritableSignal; +}; +type TestInputs = Partial> & { + numItems?: number; +}; + +function getNavigation(inputs: TestInputs = {}): ListNavigation { + const focusManager = getListFocus(inputs); + return new ListNavigation({ + focusManager, + ...focusManager.inputs, + wrap: signal(false), + textDirection: signal('ltr'), + orientation: signal('vertical'), + ...inputs, + }); +} describe('List Navigation', () => { - interface TestItem extends ListNavigationItem { - disabled: WritableSignalLike; - } - - function getItems(length: number): SignalLike { - return signal( - Array.from({length}).map((_, i) => ({ - index: signal(i), - disabled: signal(false), - })), - ); - } - - function getNavigation( - items: SignalLike, - args: Partial> = {}, - ): ListNavigation { - return new ListNavigation({ - items, - wrap: signal(false), - activeIndex: signal(0), - skipDisabled: signal(false), - textDirection: signal('ltr'), - orientation: signal('vertical'), - ...args, - }); - } - describe('#goto', () => { it('should navigate to an item', () => { - const items = getItems(5); - const nav = getNavigation(items); - + const nav = getNavigation(); expect(nav.inputs.activeIndex()).toBe(0); - nav.goto(items()[3]); + nav.goto(nav.inputs.items()[3]); expect(nav.inputs.activeIndex()).toBe(3); }); }); describe('#next', () => { it('should navigate next', () => { - const nav = getNavigation(getItems(3)); + const nav = getNavigation(); nav.next(); // 0 -> 1 expect(nav.inputs.activeIndex()).toBe(1); }); it('should wrap', () => { - const nav = getNavigation(getItems(3), { - wrap: signal(true), - }); - + const nav = getNavigation({wrap: signal(true)}); nav.next(); // 0 -> 1 nav.next(); // 1 -> 2 - nav.next(); // 2 -> 0 - + nav.next(); // 2 -> 3 + nav.next(); // 3 -> 4 + nav.next(); // 4 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); it('should not wrap', () => { - const nav = getNavigation(getItems(3), { - wrap: signal(false), - }); - + const nav = getNavigation({wrap: signal(false)}); nav.next(); // 0 -> 1 nav.next(); // 1 -> 2 - nav.next(); // 2 -> 2 - - expect(nav.inputs.activeIndex()).toBe(2); + nav.next(); // 2 -> 3 + nav.next(); // 3 -> 4 + nav.next(); // 4 -> 4 + expect(nav.inputs.activeIndex()).toBe(4); }); it('should skip disabled items', () => { - const nav = getNavigation(getItems(3), { - skipDisabled: signal(true), - }); - nav.inputs.items()[1].disabled.set(true); - + const nav = getNavigation({skipDisabled: signal(true)}); + const items = nav.inputs.items() as TestItem[]; + items[1].disabled.set(true); nav.next(); // 0 -> 2 expect(nav.inputs.activeIndex()).toBe(2); }); it('should not skip disabled items', () => { - const nav = getNavigation(getItems(3), { - skipDisabled: signal(false), - }); - nav.inputs.items()[1].disabled.set(true); - + const nav = getNavigation({skipDisabled: signal(false)}); + const items = nav.inputs.items() as TestItem[]; + items[1].disabled.set(true); nav.next(); // 0 -> 1 expect(nav.inputs.activeIndex()).toBe(1); }); it('should wrap and skip disabled items', () => { - const nav = getNavigation(getItems(3), { + const nav = getNavigation({ wrap: signal(true), skipDisabled: signal(true), }); - nav.inputs.items()[2].disabled.set(true); + const items = nav.inputs.items() as TestItem[]; + items[2].disabled.set(true); + items[3].disabled.set(true); + items[4].disabled.set(true); nav.next(); // 0 -> 1 nav.next(); // 1 -> 0 @@ -115,18 +99,18 @@ describe('List Navigation', () => { }); it('should do nothing if other items are disabled', () => { - const nav = getNavigation(getItems(3), { - skipDisabled: signal(true), - }); - nav.inputs.items()[1].disabled.set(true); - nav.inputs.items()[2].disabled.set(true); - + const nav = getNavigation({skipDisabled: signal(true)}); + const items = nav.inputs.items() as TestItem[]; + items[1].disabled.set(true); + items[2].disabled.set(true); + items[3].disabled.set(true); + items[4].disabled.set(true); nav.next(); // 0 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); it('should do nothing if there are no other items to navigate to', () => { - const nav = getNavigation(getItems(1)); + const nav = getNavigation({numItems: 1}); nav.next(); // 0 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); @@ -134,79 +118,73 @@ describe('List Navigation', () => { describe('#prev', () => { it('should navigate prev', () => { - const nav = getNavigation(getItems(3), { - activeIndex: signal(2), - }); + const nav = getNavigation({activeIndex: signal(2)}); nav.prev(); // 2 -> 1 expect(nav.inputs.activeIndex()).toBe(1); }); it('should wrap', () => { - const nav = getNavigation(getItems(3), { - wrap: signal(true), - }); - nav.prev(); // 0 -> 2 - expect(nav.inputs.activeIndex()).toBe(2); + const nav = getNavigation({wrap: signal(true)}); + nav.prev(); // 0 -> 4 + expect(nav.inputs.activeIndex()).toBe(4); }); it('should not wrap', () => { - const nav = getNavigation(getItems(3), { - wrap: signal(false), - }); + const nav = getNavigation({wrap: signal(false)}); nav.prev(); // 0 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); it('should skip disabled items', () => { - const nav = getNavigation(getItems(3), { + const nav = getNavigation({ activeIndex: signal(2), skipDisabled: signal(true), }); - nav.inputs.items()[1].disabled.set(true); - + const items = nav.inputs.items() as TestItem[]; + items[1].disabled.set(true); nav.prev(); // 2 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); it('should not skip disabled items', () => { - const nav = getNavigation(getItems(3), { + const nav = getNavigation({ activeIndex: signal(2), skipDisabled: signal(false), }); - nav.inputs.items()[1].disabled.set(true); - + const items = nav.inputs.items() as TestItem[]; + items[1].disabled.set(true); nav.prev(); // 2 -> 1 expect(nav.inputs.activeIndex()).toBe(1); }); it('should wrap and skip disabled items', () => { - const nav = getNavigation(getItems(3), { + const nav = getNavigation({ wrap: signal(true), activeIndex: signal(2), skipDisabled: signal(true), }); - nav.inputs.items()[0].disabled.set(true); - - nav.prev(); // 2 -> 1 - nav.prev(); // 1 -> 2 - - expect(nav.inputs.activeIndex()).toBe(2); + const items = nav.inputs.items() as TestItem[]; + items[0].disabled.set(true); + items[1].disabled.set(true); + nav.prev(); // 2 -> 4 + expect(nav.inputs.activeIndex()).toBe(4); }); it('should do nothing if other items are disabled', () => { - const nav = getNavigation(getItems(3), { - activeIndex: signal(2), + const nav = getNavigation({ skipDisabled: signal(true), }); - nav.inputs.items()[0].disabled.set(true); - nav.inputs.items()[1].disabled.set(true); - - nav.prev(); // 2 -> 2 - expect(nav.inputs.activeIndex()).toBe(2); + const items = nav.inputs.items() as TestItem[]; + items[1].disabled.set(true); + items[2].disabled.set(true); + items[3].disabled.set(true); + items[4].disabled.set(true); + nav.prev(); // 0 -> 0 + expect(nav.inputs.activeIndex()).toBe(0); }); it('should do nothing if there are no other items to navigate to', () => { - const nav = getNavigation(getItems(1)); + const nav = getNavigation({numItems: 1}); nav.prev(); // 0 -> 0 expect(nav.inputs.activeIndex()).toBe(0); }); @@ -214,32 +192,29 @@ describe('List Navigation', () => { describe('#first', () => { it('should navigate to the first item', () => { - const nav = getNavigation(getItems(3), { - activeIndex: signal(2), - }); - + const nav = getNavigation({activeIndex: signal(2)}); nav.first(); expect(nav.inputs.activeIndex()).toBe(0); }); it('should skip disabled items', () => { - const nav = getNavigation(getItems(3), { + const nav = getNavigation({ activeIndex: signal(2), skipDisabled: signal(true), }); - nav.inputs.items()[0].disabled.set(true); - + const items = nav.inputs.items() as TestItem[]; + items[0].disabled.set(true); nav.first(); expect(nav.inputs.activeIndex()).toBe(1); }); it('should not skip disabled items', () => { - const nav = getNavigation(getItems(3), { + const nav = getNavigation({ activeIndex: signal(2), skipDisabled: signal(false), }); - nav.inputs.items()[0].disabled.set(true); - + const items = nav.inputs.items() as TestItem[]; + items[0].disabled.set(true); nav.first(); expect(nav.inputs.activeIndex()).toBe(0); }); @@ -247,63 +222,29 @@ describe('List Navigation', () => { describe('#last', () => { it('should navigate to the last item', () => { - const nav = getNavigation(getItems(3)); + const nav = getNavigation(); nav.last(); - expect(nav.inputs.activeIndex()).toBe(2); + expect(nav.inputs.activeIndex()).toBe(4); }); it('should skip disabled items', () => { - const nav = getNavigation(getItems(3), { + const nav = getNavigation({ skipDisabled: signal(true), }); - nav.inputs.items()[2].disabled.set(true); - + const items = nav.inputs.items() as TestItem[]; + items[4].disabled.set(true); nav.last(); - expect(nav.inputs.activeIndex()).toBe(1); + expect(nav.inputs.activeIndex()).toBe(3); }); it('should not skip disabled items', () => { - const nav = getNavigation(getItems(3), { + const nav = getNavigation({ skipDisabled: signal(false), }); - nav.inputs.items()[2].disabled.set(true); - + const items = nav.inputs.items() as TestItem[]; + items[4].disabled.set(true); nav.last(); - expect(nav.inputs.activeIndex()).toBe(2); - }); - }); - - describe('#isFocusable', () => { - it('should return true for enabled items', () => { - const nav = getNavigation(getItems(3), { - skipDisabled: signal(true), - }); - - expect(nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); - expect(nav.isFocusable(nav.inputs.items()[1])).toBeTrue(); - expect(nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); - }); - - it('should return false for disabled items', () => { - const nav = getNavigation(getItems(3), { - skipDisabled: signal(true), - }); - nav.inputs.items()[1].disabled.set(true); - - expect(nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); - expect(nav.isFocusable(nav.inputs.items()[1])).toBeFalse(); - expect(nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); - }); - - it('should return true for disabled items if skip disabled is false', () => { - const nav = getNavigation(getItems(3), { - skipDisabled: signal(false), - }); - nav.inputs.items()[1].disabled.set(true); - - expect(nav.isFocusable(nav.inputs.items()[0])).toBeTrue(); - expect(nav.isFocusable(nav.inputs.items()[1])).toBeTrue(); - expect(nav.isFocusable(nav.inputs.items()[2])).toBeTrue(); + expect(nav.inputs.activeIndex()).toBe(4); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts index 200ca8d3537b..40e51bdb9742 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-navigation/list-navigation.ts @@ -6,29 +6,17 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, signal} from '@angular/core'; -import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; +import {SignalLike} from '../signal-like/signal-like'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; /** Represents an item in a collection, such as a listbox option, than can be navigated to. */ -export interface ListNavigationItem { - /** Whether an item is disabled. */ - disabled: SignalLike; -} +export interface ListNavigationItem extends ListFocusItem {} /** Represents the required inputs for a collection that has navigable items. */ -export interface ListNavigationInputs { +export interface ListNavigationInputs extends ListFocusInputs { /** Whether focus should wrap when navigating. */ wrap: SignalLike; - /** The items in the list. */ - items: SignalLike; - - /** Whether disabled items in the list should be skipped when navigating. */ - skipDisabled: SignalLike; - - /** The current index that has been navigated to. */ - activeIndex: WritableSignalLike; - /** Whether the list is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; @@ -38,23 +26,11 @@ export interface ListNavigationInputs { /** Controls navigation for a list of items. */ export class ListNavigation { - /** The last index that was active. */ - prevActiveIndex = signal(0); - - /** The current active item. */ - activeItem = computed(() => this.inputs.items()[this.inputs.activeIndex()]); - - constructor(readonly inputs: ListNavigationInputs) {} + constructor(readonly inputs: ListNavigationInputs & {focusManager: ListFocus}) {} /** Navigates to the given item. */ goto(item?: T): boolean { - if (item && this.isFocusable(item)) { - this.prevActiveIndex.set(this.inputs.activeIndex()); - const index = this.inputs.items().indexOf(item); - this.inputs.activeIndex.set(index); - return true; - } - return false; + return item ? this.inputs.focusManager.focus(item) : false; } /** Navigates to the next item in the list. */ @@ -69,7 +45,7 @@ export class ListNavigation { /** Navigates to the first item in the list. */ first(): boolean { - const item = this.inputs.items().find(i => this.isFocusable(i)); + const item = this.inputs.items().find(i => this.inputs.focusManager.isFocusable(i)); return item ? this.goto(item) : false; } @@ -77,18 +53,13 @@ export class ListNavigation { last(): boolean { const items = this.inputs.items(); for (let i = items.length - 1; i >= 0; i--) { - if (this.isFocusable(items[i])) { + if (this.inputs.focusManager.isFocusable(items[i])) { return this.goto(items[i]); } } return false; } - /** Returns true if the given item can be navigated to. */ - isFocusable(item: T): boolean { - return !item.disabled() || !this.inputs.skipDisabled(); - } - /** Advances to the next or previous focusable item in the list based on the given delta. */ private _advance(delta: 1 | -1): boolean { const items = this.inputs.items(); @@ -101,7 +72,7 @@ export class ListNavigation { // in the case that all options are disabled. If wrapping is disabled, the loop terminates // when the index goes out of bounds. for (let i = step(startIndex); i !== startIndex && i < itemCount && i >= 0; i = step(i)) { - if (this.isFocusable(items[i])) { + if (this.inputs.focusManager.isFocusable(items[i])) { return this.goto(items[i]); } } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-selection/BUILD.bazel index 9897dbd264e0..1b163af1b9eb 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/BUILD.bazel @@ -11,7 +11,7 @@ ts_project( ), deps = [ "//:node_modules/@angular/core", - "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) @@ -23,8 +23,7 @@ ts_project( deps = [ ":list-selection", "//:node_modules/@angular/core", - "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", - "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus:unit_test_sources", ], ) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts index da9f28cf5a7f..aceb03718cd2 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.spec.ts @@ -6,151 +6,116 @@ * found in the LICENSE file at https://angular.dev/license */ -import {signal} from '@angular/core'; -import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; +import {Signal, signal, WritableSignal} from '@angular/core'; import {ListSelectionItem, ListSelection, ListSelectionInputs} from './list-selection'; -import {ListNavigation, ListNavigationInputs} from '../list-navigation/list-navigation'; - -describe('List Selection', () => { - interface TestItem extends ListSelectionItem { - disabled: WritableSignalLike; - } - - function getItems(values: V[]): SignalLike[]> { - return signal( - values.map((value, i) => ({ - index: signal(i), - value: signal(value), +import {getListFocus} from '../list-focus/list-focus.spec'; +import {ListFocus} from '../list-focus/list-focus'; + +type TestItem = ListSelectionItem & { + disabled: WritableSignal; +}; +type TestInputs = Partial> & { + numItems?: number; +}; + +function getSelection(inputs: TestInputs = {}): ListSelection, number> { + const items = getItems(inputs.numItems ?? 5); + const focusManager = getListFocus({...inputs, items}) as ListFocus; + + return new ListSelection({ + focusManager, + ...focusManager.inputs, + items, + value: signal([]), + multi: signal(false), + selectionMode: signal('follow'), + ...inputs, + }); +} + +function getItems(length: number): Signal { + return signal( + Array.from({length}).map((_, i) => { + return { + value: signal(i), + id: signal(`${i}`), disabled: signal(false), - isAnchor: signal(false), - })), - ); - } - - function getNavigation, V>( - items: SignalLike, - args: Partial> = {}, - ): ListNavigation { - return new ListNavigation({ - items, - wrap: signal(false), - activeIndex: signal(0), - skipDisabled: signal(false), - textDirection: signal('ltr'), - orientation: signal('vertical'), - ...args, - }); - } - - function getSelection, V>( - items: SignalLike, - navigation: ListNavigation, - args: Partial> = {}, - ): ListSelection { - return new ListSelection({ - items, - navigation, - value: signal([]), - multi: signal(true), - selectionMode: signal('explicit'), - ...args, - }); - } + element: signal({focus: () => {}} as HTMLElement), + }; + }), + ); +} +describe('List Selection', () => { describe('#select', () => { it('should select an item', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - + const selection = getSelection(); selection.select(); // [0] expect(selection.inputs.value()).toEqual([0]); }); it('should select multiple options', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; selection.select(); // [0] - nav.next(); + selection.inputs.focusManager.focus(items[1]); selection.select(); // [0, 1] expect(selection.inputs.value()).toEqual([0, 1]); }); it('should not select multiple options', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav, { - multi: signal(false), - }); - + const selection = getSelection({multi: signal(false)}); + const items = selection.inputs.items() as TestItem[]; selection.select(); // [0] - nav.next(); + selection.inputs.focusManager.focus(items[1]); selection.select(); // [1] - expect(selection.inputs.value()).toEqual([1]); }); it('should not select disabled items', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - items()[0].disabled.set(true); - + const selection = getSelection(); + const items = selection.inputs.items() as TestItem[]; + items[0].disabled.set(true); selection.select(); // [] expect(selection.inputs.value()).toEqual([]); }); it('should do nothing to already selected items', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - + const selection = getSelection(); selection.select(); // [0] selection.select(); // [0] - expect(selection.inputs.value()).toEqual([0]); }); }); describe('#deselect', () => { it('should deselect an item', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection(); selection.deselect(); // [] expect(selection.inputs.value().length).toBe(0); }); it('should not deselect disabled items', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - + const selection = getSelection(); + const items = selection.inputs.items() as TestItem[]; selection.select(); // [0] - items()[0].disabled.set(true); + items[0].disabled.set(true); selection.deselect(); // [0] - expect(selection.inputs.value()).toEqual([0]); }); }); describe('#toggle', () => { it('should select an unselected item', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - + const selection = getSelection(); selection.toggle(); // [0] expect(selection.inputs.value()).toEqual([0]); }); it('should deselect a selected item', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection(); selection.select(); // [0] selection.toggle(); // [] expect(selection.inputs.value().length).toBe(0); @@ -159,29 +124,23 @@ describe('List Selection', () => { describe('#toggleOne', () => { it('should select an unselected item', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - + const selection = getSelection({multi: signal(true)}); selection.toggleOne(); // [0] expect(selection.inputs.value()).toEqual([0]); }); it('should deselect a selected item', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection({multi: signal(true)}); selection.select(); // [0] selection.toggleOne(); // [] expect(selection.inputs.value().length).toBe(0); }); it('should only leave one item selected', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; selection.select(); // [0] - nav.next(); + selection.inputs.focusManager.focus(items[1]); selection.toggleOne(); // [1] expect(selection.inputs.value()).toEqual([1]); }); @@ -189,27 +148,22 @@ describe('List Selection', () => { describe('#selectAll', () => { it('should select all items', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection({multi: signal(true)}); selection.selectAll(); expect(selection.inputs.value()).toEqual([0, 1, 2, 3, 4]); }); it('should do nothing if a list is not multiselectable', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection({multi: signal(false)}); selection.selectAll(); - expect(selection.inputs.value()).toEqual([0, 1, 2, 3, 4]); + expect(selection.inputs.value()).toEqual([]); }); }); describe('#deselectAll', () => { it('should deselect all items', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection({multi: signal(true)}); + selection.selectAll(); // [0, 1, 2, 3, 4] selection.deselectAll(); // [] expect(selection.inputs.value().length).toBe(0); }); @@ -217,27 +171,22 @@ describe('List Selection', () => { describe('#toggleAll', () => { it('should select all items', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection({multi: signal(true)}); selection.toggleAll(); expect(selection.inputs.value()).toEqual([0, 1, 2, 3, 4]); }); it('should deselect all if all items are selected', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection({multi: signal(true)}); selection.selectAll(); selection.toggleAll(); expect(selection.inputs.value()).toEqual([]); }); it('should ignore disabled items when determining if all items are selected', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - items()[0].disabled.set(true); + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + items[0].disabled.set(true); selection.toggleAll(); expect(selection.inputs.value()).toEqual([1, 2, 3, 4]); selection.toggleAll(); @@ -247,123 +196,107 @@ describe('List Selection', () => { describe('#selectOne', () => { it('should select a single item', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; selection.selectOne(); // [0] - nav.next(); + selection.inputs.focusManager.focus(items[1]); selection.selectOne(); // [1] expect(selection.inputs.value()).toEqual([1]); }); it('should not select disabled items', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - items()[0].disabled.set(true); + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + items[0].disabled.set(true); selection.select(); // [] expect(selection.inputs.value()).toEqual([]); }); it('should do nothing to already selected items', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - + const selection = getSelection({multi: signal(true)}); selection.selectOne(); // [0] selection.selectOne(); // [0] - expect(selection.inputs.value()).toEqual([0]); }); }); describe('#selectRange', () => { it('should select all items from an anchor at a lower index', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; selection.select(); // [0] - nav.next(); - nav.next(); + selection.inputs.focusManager.focus(items[2]); selection.selectRange(); // [0, 1, 2] - expect(selection.inputs.value()).toEqual([0, 1, 2]); }); it('should select all items from an anchor at a higher index', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items, { + const selection = getSelection({ + multi: signal(true), activeIndex: signal(3), }); - const selection = getSelection(items, nav); + const items = selection.inputs.items() as TestItem[]; selection.select(); // [3] - nav.prev(); - nav.prev(); + selection.inputs.focusManager.focus(items[1]); selection.selectRange(); // [3, 2, 1] expect(selection.inputs.value()).toEqual([3, 2, 1]); }); it('should deselect items within the range when the range is changed', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection({ + multi: signal(true), + activeIndex: signal(2), + }); + const items = selection.inputs.items() as TestItem[]; - nav.next(); - nav.next(); selection.select(); // [2] expect(selection.inputs.value()).toEqual([2]); - nav.next(); - nav.next(); + selection.inputs.focusManager.focus(items[4]); selection.selectRange(); // [2, 3, 4] expect(selection.inputs.value()).toEqual([2, 3, 4]); - nav.first(); + selection.inputs.focusManager.focus(items[0]); selection.selectRange(); // [2, 1, 0] expect(selection.inputs.value()).toEqual([2, 1, 0]); }); it('should not select a disabled item', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - items()[1].disabled.set(true); + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + items[1].disabled.set(true); selection.select(); // [0] expect(selection.inputs.value()).toEqual([0]); - nav.next(); + selection.inputs.focusManager.focus(items[1]); selection.selectRange(); // [0] expect(selection.inputs.value()).toEqual([0]); - nav.next(); + selection.inputs.focusManager.focus(items[2]); selection.selectRange(); // [0, 2] expect(selection.inputs.value()).toEqual([0, 2]); }); it('should not deselect a disabled item', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; - selection.select(items()[1]); - items()[1].disabled.set(true); + selection.select(items[1]); + items[1].disabled.set(true); selection.select(); // [0] + selection.inputs.focusManager.focus(items[0]); expect(selection.inputs.value()).toEqual([1, 0]); - nav.next(); - nav.next(); + selection.inputs.focusManager.focus(items[2]); selection.selectRange(); // [0, 1, 2] expect(selection.inputs.value()).toEqual([1, 0, 2]); - nav.prev(); - nav.prev(); + selection.inputs.focusManager.focus(items[0]); selection.selectRange(); // [0] expect(selection.inputs.value()).toEqual([1, 0]); }); @@ -371,28 +304,22 @@ describe('List Selection', () => { describe('#beginRangeSelection', () => { it('should set where a range is starting from', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - - nav.next(); - nav.next(); + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + selection.inputs.focusManager.focus(items[2]); selection.beginRangeSelection(); expect(selection.inputs.value()).toEqual([]); - nav.next(); - nav.next(); + selection.inputs.focusManager.focus(items[4]); selection.selectRange(); // [2, 3, 4] expect(selection.inputs.value()).toEqual([2, 3, 4]); }); it('should be able to select a range starting on a disabled item', () => { - const items = getItems([0, 1, 2, 3, 4]); - const nav = getNavigation(items); - const selection = getSelection(items, nav); - items()[0].disabled.set(true); + const selection = getSelection({multi: signal(true)}); + const items = selection.inputs.items() as TestItem[]; + items[0].disabled.set(true); selection.beginRangeSelection(0); - nav.next(); - nav.next(); + selection.inputs.focusManager.focus(items[2]); selection.selectRange(); expect(selection.inputs.value()).toEqual([1, 2]); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts index 4dc4c1fc9820..639c292c93d4 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts @@ -8,22 +8,16 @@ import {signal} from '@angular/core'; import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; -import {ListNavigation, ListNavigationItem} from '../list-navigation/list-navigation'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; /** Represents an item in a collection, such as a listbox option, than can be selected. */ -export interface ListSelectionItem extends ListNavigationItem { +export interface ListSelectionItem extends ListFocusItem { /** The value of the item. */ value: SignalLike; - - /** Whether an item is disabled. */ - disabled: SignalLike; } /** Represents the required inputs for a collection that contains selectable items. */ -export interface ListSelectionInputs, V> { - /** The items in the list. */ - items: SignalLike; - +export interface ListSelectionInputs, V> extends ListFocusInputs { /** Whether multiple items in the list can be selected at once. */ multi: SignalLike; @@ -42,16 +36,11 @@ export class ListSelection, V> { /** The end index to use for range selection. */ rangeEndIndex = signal(0); - /** The navigation controller of the parent list. */ - navigation: ListNavigation; - - constructor(readonly inputs: ListSelectionInputs & {navigation: ListNavigation}) { - this.navigation = inputs.navigation; - } + constructor(readonly inputs: ListSelectionInputs & {focusManager: ListFocus}) {} /** Selects the item at the current active index. */ - select(item?: T, opts = {anchor: true}) { - item = item ?? this.inputs.navigation.activeItem(); + select(item?: ListSelectionItem, opts = {anchor: true}) { + item = item ?? (this.inputs.focusManager.activeItem() as ListSelectionItem); if (item.disabled() || this.inputs.value().includes(item.value())) { return; @@ -70,7 +59,7 @@ export class ListSelection, V> { /** Deselects the item at the current active index. */ deselect(item?: T) { - item = item ?? this.inputs.navigation.activeItem(); + item = item ?? this.inputs.focusManager.activeItem(); if (!item.disabled()) { this.inputs.value.update(values => values.filter(value => value !== item.value())); @@ -79,13 +68,13 @@ export class ListSelection, V> { /** Toggles the item at the current active index. */ toggle() { - const item = this.inputs.navigation.activeItem(); + const item = this.inputs.focusManager.activeItem(); this.inputs.value().includes(item.value()) ? this.deselect() : this.select(); } /** Toggles only the item at the current active index. */ toggleOne() { - const item = this.inputs.navigation.activeItem(); + const item = this.inputs.focusManager.activeItem(); this.inputs.value().includes(item.value()) ? this.deselect() : this.selectOne(); } @@ -137,10 +126,10 @@ export class ListSelection, V> { * selected range that are now outside of the selected range */ selectRange(opts = {anchor: true}) { - const isStartOfRange = this.navigation.prevActiveIndex() === this.rangeStartIndex(); + const isStartOfRange = this.inputs.focusManager.prevActiveIndex() === this.rangeStartIndex(); if (isStartOfRange && opts.anchor) { - this.beginRangeSelection(this.navigation.prevActiveIndex()); + this.beginRangeSelection(this.inputs.focusManager.prevActiveIndex()); } const itemsInRange = this._getItemsFromIndex(this.rangeStartIndex()); @@ -164,7 +153,7 @@ export class ListSelection, V> { } /** Marks the given index as the start of a range selection. */ - beginRangeSelection(index: number = this.navigation.inputs.activeIndex()) { + beginRangeSelection(index: number = this.inputs.activeIndex()) { this.rangeStartIndex.set(index); this.rangeEndIndex.set(index); } @@ -175,15 +164,15 @@ export class ListSelection, V> { return []; } - const upper = Math.max(this.inputs.navigation.inputs.activeIndex(), index); - const lower = Math.min(this.inputs.navigation.inputs.activeIndex(), index); + const upper = Math.max(this.inputs.activeIndex(), index); + const lower = Math.min(this.inputs.activeIndex(), index); const items = []; for (let i = lower; i <= upper; i++) { items.push(this.inputs.items()[i]); } - if (this.inputs.navigation.inputs.activeIndex() < index) { + if (this.inputs.activeIndex() < index) { return items.reverse(); } diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/BUILD.bazel index 35ad3e22ce25..288b6cf0752c 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/behaviors/list-typeahead/BUILD.bazel @@ -11,7 +11,7 @@ ts_project( ), deps = [ "//:node_modules/@angular/core", - "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) @@ -23,8 +23,7 @@ ts_project( deps = [ ":list-typeahead", "//:node_modules/@angular/core", - "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", - "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus:unit_test_sources", ], ) 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 7ce0e56f0c0e..421c2a7de6ee 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 @@ -6,82 +6,89 @@ * found in the LICENSE file at https://angular.dev/license */ -import {signal} from '@angular/core'; -import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; -import {ListTypeaheadItem, ListTypeahead} from './list-typeahead'; +import {Signal, signal, WritableSignal} from '@angular/core'; +import {ListTypeaheadItem, ListTypeahead, ListTypeaheadInputs} from './list-typeahead'; import {fakeAsync, tick} from '@angular/core/testing'; -import {ListNavigation} from '../list-navigation/list-navigation'; +import {getListFocus} from '../list-focus/list-focus.spec'; +import {ListFocus} from '../list-focus/list-focus'; + +type TestItem = ListTypeaheadItem & { + disabled: WritableSignal; +}; +type TestInputs = Partial> & { + numItems?: number; +}; + +function getTypeahead(inputs: TestInputs = {}): ListTypeahead { + const items = getItems(inputs.numItems ?? 5); + const focusManager = getListFocus({...inputs, items}) as ListFocus; + + return new ListTypeahead({ + focusManager, + ...focusManager.inputs, + items, + typeaheadDelay: signal(0.5), + ...inputs, + }); +} -describe('List Typeahead', () => { - interface TestItem extends ListTypeaheadItem { - disabled: WritableSignalLike; - } - - function getItems(length: number): SignalLike { - return signal( - Array.from({length}).map((_, i) => ({ - index: signal(i), - disabled: signal(false), +function getItems(length: number): Signal { + return signal( + Array.from({length}).map((_, i) => { + return { searchTerm: signal(`Item ${i}`), - })), - ); - } + id: signal(`${i}`), + disabled: signal(false), + element: signal({focus: () => {}} as HTMLElement), + }; + }), + ); +} - let items: SignalLike; +describe('List Typeahead', () => { + let items: TestItem[]; 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), - }); + typeahead = getTypeahead(); + items = typeahead.inputs.items(); }); describe('#search', () => { it('should navigate to an item', () => { typeahead.search('i'); - expect(navigation.inputs.activeIndex()).toBe(1); + expect(typeahead.inputs.activeIndex()).toBe(1); typeahead.search('t'); typeahead.search('e'); typeahead.search('m'); typeahead.search(' '); typeahead.search('3'); - expect(navigation.inputs.activeIndex()).toBe(3); + expect(typeahead.inputs.activeIndex()).toBe(3); }); it('should reset after a delay', fakeAsync(() => { typeahead.search('i'); - expect(navigation.inputs.activeIndex()).toBe(1); + expect(typeahead.inputs.activeIndex()).toBe(1); tick(500); typeahead.search('i'); - expect(navigation.inputs.activeIndex()).toBe(2); + expect(typeahead.inputs.activeIndex()).toBe(2); })); it('should skip disabled items', () => { - items()[1].disabled.set(true); - (navigation.inputs.skipDisabled as WritableSignalLike).set(true); + items[1].disabled.set(true); + (typeahead.inputs.skipDisabled as WritableSignal).set(true); typeahead.search('i'); - expect(navigation.inputs.activeIndex()).toBe(2); + expect(typeahead.inputs.activeIndex()).toBe(2); }); it('should not skip disabled items', () => { - items()[1].disabled.set(true); - (navigation.inputs.skipDisabled as WritableSignalLike).set(false); + items[1].disabled.set(true); + (typeahead.inputs.skipDisabled as WritableSignal).set(false); typeahead.search('i'); - expect(navigation.inputs.activeIndex()).toBe(1); + expect(typeahead.inputs.activeIndex()).toBe(1); }); it('should ignore keys like shift', () => { @@ -94,7 +101,7 @@ describe('List Typeahead', () => { typeahead.search('m'); typeahead.search(' '); typeahead.search('2'); - expect(navigation.inputs.activeIndex()).toBe(2); + expect(typeahead.inputs.activeIndex()).toBe(2); }); it('should not allow a query to begin with a space', () => { @@ -105,7 +112,7 @@ describe('List Typeahead', () => { typeahead.search('m'); typeahead.search(' '); typeahead.search('3'); - expect(navigation.inputs.activeIndex()).toBe(3); + expect(typeahead.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 728e1e9709ca..b1e717a7ec51 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 @@ -8,13 +8,13 @@ import {computed, signal} from '@angular/core'; import {SignalLike} from '../signal-like/signal-like'; -import {ListNavigationItem, ListNavigation} from '../list-navigation/list-navigation'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; /** * Represents an item in a collection, such as a listbox option, than can be navigated to by * typeahead. */ -export interface ListTypeaheadItem extends ListNavigationItem { +export interface ListTypeaheadItem extends ListFocusItem { /** The text used by the typeahead search. */ searchTerm: SignalLike; } @@ -23,7 +23,7 @@ export interface ListTypeaheadItem extends ListNavigationItem { * Represents the required inputs for a collection that contains items that can be navigated to by * typeahead. */ -export interface ListTypeaheadInputs { +export interface ListTypeaheadInputs extends ListFocusInputs { /** The amount of time before the typeahead search is reset. */ typeaheadDelay: SignalLike; } @@ -33,8 +33,8 @@ export class ListTypeahead { /** A reference to the timeout for resetting the typeahead search. */ timeout?: ReturnType | undefined; - /** The navigation controller of the parent list. */ - navigation: ListNavigation; + /** The focus controller of the parent list. */ + focusManager: ListFocus; /** Whether the user is actively typing a typeahead search query. */ isTyping = computed(() => this._query().length > 0); @@ -45,8 +45,8 @@ export class ListTypeahead { /** The index where that the typeahead search was initiated from. */ private _startIndex = signal(undefined); - constructor(readonly inputs: ListTypeaheadInputs & {navigation: ListNavigation}) { - this.navigation = inputs.navigation; + constructor(readonly inputs: ListTypeaheadInputs & {focusManager: ListFocus}) { + this.focusManager = inputs.focusManager; } /** Performs a typeahead search, appending the given character to the search string. */ @@ -60,7 +60,7 @@ export class ListTypeahead { } if (this._startIndex() === undefined) { - this._startIndex.set(this.navigation.inputs.activeIndex()); + this._startIndex.set(this.focusManager.inputs.activeIndex()); } clearTimeout(this.timeout); @@ -68,7 +68,7 @@ export class ListTypeahead { const item = this._getItem(); if (item) { - this.navigation.goto(item); + this.focusManager.focus(item); } this.timeout = setTimeout(() => { @@ -84,15 +84,15 @@ export class ListTypeahead { * current query starting from the the current anchor index. */ private _getItem() { - let items = this.navigation.inputs.items(); + let items = this.focusManager.inputs.items(); const after = items.slice(this._startIndex()! + 1); const before = items.slice(0, this._startIndex()!); - items = this.navigation.inputs.wrap() ? after.concat(before) : after; // TODO: Always wrap? - items.push(this.navigation.inputs.items()[this._startIndex()!]); + items = after.concat(before); + items.push(this.inputs.items()[this._startIndex()!]); const focusableItems = []; for (const item of items) { - if (this.navigation.isFocusable(item)) { + if (this.focusManager.isFocusable(item)) { focusableItems.push(item); } } diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index fbc54050ce9e..4a6f5491c3f8 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -28,9 +28,8 @@ interface SelectOptions { /** Represents the required inputs for a listbox. */ export type ListboxInputs = ListNavigationInputs> & ListSelectionInputs, V> & - ListTypeaheadInputs & + ListTypeaheadInputs> & ListFocusInputs> & { - disabled: SignalLike; readonly: SignalLike; }; @@ -52,7 +51,7 @@ export class ListboxPattern { orientation: SignalLike<'vertical' | 'horizontal'>; /** Whether the listbox is disabled. */ - disabled: SignalLike; + disabled = computed(() => this.focusManager.isListDisabled()); /** Whether the listbox is readonly. */ readonly: SignalLike; @@ -224,18 +223,18 @@ export class ListboxPattern { }); constructor(readonly inputs: ListboxInputs) { - this.disabled = inputs.disabled; this.readonly = inputs.readonly; this.orientation = inputs.orientation; this.multi = inputs.multi; + this.focusManager = new ListFocus(inputs); + this.selection = new ListSelection({...inputs, focusManager: this.focusManager}); + this.typeahead = new ListTypeahead({...inputs, focusManager: this.focusManager}); this.navigation = new ListNavigation({ ...inputs, + focusManager: this.focusManager, wrap: computed(() => this.wrap() && this.inputs.wrap()), }); - this.selection = new ListSelection({...inputs, navigation: this.navigation}); - this.typeahead = new ListTypeahead({...inputs, navigation: this.navigation}); - this.focusManager = new ListFocus({...inputs, navigation: this.navigation}); } /** Handles keydown events for the listbox. */ @@ -300,7 +299,6 @@ export class ListboxPattern { const moved = operation(); if (moved) { - this.focusManager.focus(); this._updateSelection(opts); } diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index ebeed3850c43..f1063464eeab 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -187,13 +187,13 @@ export class TabListPattern { this.disabled = inputs.disabled; this.orientation = inputs.orientation; - this.navigation = new ListNavigation(inputs); + this.focusManager = new ListFocus(inputs); + this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager}); this.selection = new ListSelection({ ...inputs, - navigation: this.navigation, multi: signal(false), + focusManager: this.focusManager, }); - this.focusManager = new ListFocus({...inputs, navigation: this.navigation}); } /** Handles keydown events for the tablist. */ @@ -213,28 +213,24 @@ export class TabListPattern { /** Navigates to the first option in the tablist. */ first(opts?: SelectOptions) { this.navigation.first(); - this.focusManager.focus(); this._updateSelection(opts); } /** Navigates to the last option in the tablist. */ last(opts?: SelectOptions) { this.navigation.last(); - this.focusManager.focus(); this._updateSelection(opts); } /** Navigates to the next option in the tablist. */ next(opts?: SelectOptions) { this.navigation.next(); - this.focusManager.focus(); this._updateSelection(opts); } /** Navigates to the previous option in the tablist. */ prev(opts?: SelectOptions) { this.navigation.prev(); - this.focusManager.focus(); this._updateSelection(opts); } @@ -244,7 +240,6 @@ export class TabListPattern { if (item) { this.navigation.goto(item); - this.focusManager.focus(); this._updateSelection(opts); } } From 15434244e06d9bec0326c8cbed6ffd62f93db837 Mon Sep 17 00:00:00 2001 From: Wagner Maciel Date: Tue, 22 Apr 2025 17:02:28 -0400 Subject: [PATCH 2/2] fixup! refactor(cdk-experimental/ui-patterns): move active index operations into list-focus --- .../ui-patterns/behaviors/list-focus/list-focus.spec.ts | 2 -- .../ui-patterns/behaviors/list-focus/list-focus.ts | 5 ++--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts index 1e0f0eedb9aa..6e94e1164dd0 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.spec.ts @@ -127,6 +127,4 @@ describe('List Focus', () => { expect(focusManager.isFocusable(items[2])).toBeTrue(); }); }); - - describe('#focus', () => {}); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts index cd6ffec292dc..a327507d368e 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list-focus/list-focus.ts @@ -81,11 +81,10 @@ export class ListFocus { if (this.inputs.focusMode() === 'activedescendant') { return -1; } - const index = this.inputs.items().indexOf(item); - return this.inputs.activeIndex() === index ? 0 : -1; + return this.activeItem() === item ? 0 : -1; } - /** Focuses the current active item. */ + /** Moves focus to the given item if it is focusable. */ focus(item: T): boolean { if (this.isListDisabled() || !this.isFocusable(item)) { return false;