diff --git a/src/cdk-experimental/accordion/accordion.ts b/src/cdk-experimental/accordion/accordion.ts index 5a761728bf2e..b5a877ce883c 100644 --- a/src/cdk-experimental/accordion/accordion.ts +++ b/src/cdk-experimental/accordion/accordion.ts @@ -177,9 +177,9 @@ export class CdkAccordionGroup { /** The UI pattern instance for this accordion group. */ readonly pattern: AccordionGroupPattern = new AccordionGroupPattern({ ...this, - // TODO(ok7sai): Consider making `activeIndex` an internal state in the pattern and call + // TODO(ok7sai): Consider making `activeItem` an internal state in the pattern and call // `setDefaultState` in the CDK. - activeIndex: signal(0), + activeItem: signal(undefined), items: computed(() => this._triggers().map(trigger => trigger.pattern)), expandedIds: this.value, // TODO(ok7sai): Investigate whether an accordion should support horizontal mode. diff --git a/src/cdk-experimental/listbox/listbox.ts b/src/cdk-experimental/listbox/listbox.ts index d310f98a7f20..98bd4e742446 100644 --- a/src/cdk-experimental/listbox/listbox.ts +++ b/src/cdk-experimental/listbox/listbox.ts @@ -103,7 +103,7 @@ export class CdkListbox { pattern: ListboxPattern = new ListboxPattern({ ...this, items: this.items, - activeIndex: signal(0), // TODO: Use linkedSignal to ensure this doesn't get fked up. + activeItem: signal(undefined), textDirection: this.textDirection, }); diff --git a/src/cdk-experimental/radio-group/radio-group.ts b/src/cdk-experimental/radio-group/radio-group.ts index effbfdffc3c1..8029142a6fda 100644 --- a/src/cdk-experimental/radio-group/radio-group.ts +++ b/src/cdk-experimental/radio-group/radio-group.ts @@ -129,7 +129,7 @@ export class CdkRadioGroup { ...this, items: this.items, value: this._value, - activeIndex: signal(0), + activeItem: signal(undefined), textDirection: this.textDirection, }); diff --git a/src/cdk-experimental/tabs/tabs.ts b/src/cdk-experimental/tabs/tabs.ts index 29c6fcd6a0cb..f1d75e982c50 100644 --- a/src/cdk-experimental/tabs/tabs.ts +++ b/src/cdk-experimental/tabs/tabs.ts @@ -173,7 +173,7 @@ export class CdkTabList implements OnInit, OnDestroy { ...this, items: this.tabs, value: this._selection, - activeIndex: signal(0), + activeItem: signal(undefined), }); /** Whether the tree has received focus yet. */ diff --git a/src/cdk-experimental/tree/tree.ts b/src/cdk-experimental/tree/tree.ts index 77f3e2eccf56..2ad9df8fe258 100644 --- a/src/cdk-experimental/tree/tree.ts +++ b/src/cdk-experimental/tree/tree.ts @@ -126,7 +126,7 @@ export class CdkTree { allItems: computed(() => [...this._unorderedItems()].sort(sortDirectives).map(item => item.pattern), ), - activeIndex: signal(0), + activeItem: signal(undefined), }); /** Whether the tree has received focus yet. */ diff --git a/src/cdk-experimental/ui-patterns/accordion/accordion.spec.ts b/src/cdk-experimental/ui-patterns/accordion/accordion.spec.ts index bc076833e78d..37e102999e2c 100644 --- a/src/cdk-experimental/ui-patterns/accordion/accordion.spec.ts +++ b/src/cdk-experimental/ui-patterns/accordion/accordion.spec.ts @@ -61,7 +61,7 @@ describe('Accordion Pattern', () => { groupInputs = { orientation: signal('vertical'), textDirection: signal('ltr'), - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), multiExpandable: signal(true), items: signal([]), @@ -104,6 +104,8 @@ describe('Accordion Pattern', () => { new AccordionTriggerPattern(triggerInputs[2]), ]; + groupPattern.inputs.activeItem.set(triggerPatterns[0]); + // Initiate a list of AccordionPanelPattern. panelInputs = [ { @@ -167,7 +169,7 @@ describe('Accordion Pattern', () => { }); it('navigates to first accordion trigger with home key.', () => { - groupInputs.activeIndex.set(2); + groupInputs.activeItem.set(groupInputs.items()[2]); expect(triggerPatterns[2].active()).toBeTrue(); triggerPatterns[2].onKeydown(home()); expect(triggerPatterns[2].active()).toBeFalse(); @@ -175,7 +177,7 @@ describe('Accordion Pattern', () => { }); it('navigates to last accordion trigger with end key.', () => { - groupInputs.activeIndex.set(0); + groupInputs.activeItem.set(groupInputs.items()[0]); expect(triggerPatterns[0].active()).toBeTrue(); triggerPatterns[0].onKeydown(end()); expect(triggerPatterns[0].active()).toBeFalse(); @@ -184,7 +186,7 @@ describe('Accordion Pattern', () => { describe('Vertical Orientation (orientation=vertical)', () => { it('navigates to the next trigger with down key.', () => { - groupInputs.activeIndex.set(0); + groupInputs.activeItem.set(groupInputs.items()[0]); expect(triggerPatterns[0].active()).toBeTrue(); expect(triggerPatterns[1].active()).toBeFalse(); triggerPatterns[0].onKeydown(down()); @@ -193,7 +195,7 @@ describe('Accordion Pattern', () => { }); it('navigates to the previous trigger with up key.', () => { - groupInputs.activeIndex.set(1); + groupInputs.activeItem.set(groupInputs.items()[1]); expect(triggerPatterns[0].active()).toBeFalse(); expect(triggerPatterns[1].active()).toBeTrue(); triggerPatterns[1].onKeydown(up()); @@ -207,7 +209,7 @@ describe('Accordion Pattern', () => { }); it('navigates to the last trigger with up key from first trigger.', () => { - groupInputs.activeIndex.set(0); + groupInputs.activeItem.set(groupInputs.items()[0]); expect(triggerPatterns[0].active()).toBeTrue(); expect(triggerPatterns[2].active()).toBeFalse(); triggerPatterns[0].onKeydown(up()); @@ -216,7 +218,7 @@ describe('Accordion Pattern', () => { }); it('navigates to the first trigger with down key from last trigger.', () => { - groupInputs.activeIndex.set(2); + groupInputs.activeItem.set(groupInputs.items()[2]); expect(triggerPatterns[0].active()).toBeFalse(); expect(triggerPatterns[2].active()).toBeTrue(); triggerPatterns[2].onKeydown(down()); @@ -231,14 +233,14 @@ describe('Accordion Pattern', () => { }); it('stays on the first trigger with up key from first trigger.', () => { - groupInputs.activeIndex.set(0); + groupInputs.activeItem.set(groupInputs.items()[0]); expect(triggerPatterns[0].active()).toBeTrue(); triggerPatterns[0].onKeydown(up()); expect(triggerPatterns[0].active()).toBeTrue(); }); it('stays on the last trigger with down key from last trigger.', () => { - groupInputs.activeIndex.set(2); + groupInputs.activeItem.set(groupInputs.items()[2]); expect(triggerPatterns[2].active()).toBeTrue(); triggerPatterns[2].onKeydown(down()); expect(triggerPatterns[2].active()).toBeTrue(); @@ -252,7 +254,7 @@ describe('Accordion Pattern', () => { }); it('navigates to the next trigger with right key.', () => { - groupInputs.activeIndex.set(0); + groupInputs.activeItem.set(groupInputs.items()[0]); expect(triggerPatterns[0].active()).toBeTrue(); expect(triggerPatterns[1].active()).toBeFalse(); triggerPatterns[0].onKeydown(right()); @@ -261,7 +263,7 @@ describe('Accordion Pattern', () => { }); it('navigates to the previous trigger with left key.', () => { - groupInputs.activeIndex.set(1); + groupInputs.activeItem.set(groupInputs.items()[1]); expect(triggerPatterns[0].active()).toBeFalse(); expect(triggerPatterns[1].active()).toBeTrue(); triggerPatterns[1].onKeydown(left()); @@ -275,7 +277,7 @@ describe('Accordion Pattern', () => { }); it('navigates to the last trigger with left key from first trigger.', () => { - groupInputs.activeIndex.set(0); + groupInputs.activeItem.set(groupInputs.items()[0]); expect(triggerPatterns[0].active()).toBeTrue(); expect(triggerPatterns[2].active()).toBeFalse(); triggerPatterns[0].onKeydown(left()); @@ -284,7 +286,7 @@ describe('Accordion Pattern', () => { }); it('navigates to the first trigger with right key from last trigger.', () => { - groupInputs.activeIndex.set(2); + groupInputs.activeItem.set(groupInputs.items()[2]); expect(triggerPatterns[2].active()).toBeTrue(); expect(triggerPatterns[0].active()).toBeFalse(); triggerPatterns[2].onKeydown(right()); @@ -299,14 +301,14 @@ describe('Accordion Pattern', () => { }); it('stays on the first trigger with left key from first trigger.', () => { - groupInputs.activeIndex.set(0); + groupInputs.activeItem.set(groupInputs.items()[0]); expect(triggerPatterns[0].active()).toBeTrue(); triggerPatterns[0].onKeydown(left()); expect(triggerPatterns[0].active()).toBeTrue(); }); it('stays on the last trigger with right key from last trigger.', () => { - groupInputs.activeIndex.set(2); + groupInputs.activeItem.set(groupInputs.items()[2]); expect(triggerPatterns[2].active()).toBeTrue(); triggerPatterns[2].onKeydown(right()); expect(triggerPatterns[2].active()).toBeTrue(); diff --git a/src/cdk-experimental/ui-patterns/accordion/accordion.ts b/src/cdk-experimental/ui-patterns/accordion/accordion.ts index 6664161f250d..6de91d38ee13 100644 --- a/src/cdk-experimental/ui-patterns/accordion/accordion.ts +++ b/src/cdk-experimental/ui-patterns/accordion/accordion.ts @@ -48,7 +48,7 @@ export class AccordionGroupPattern { this.wrap = inputs.wrap; this.orientation = inputs.orientation; this.textDirection = inputs.textDirection; - this.activeIndex = inputs.activeIndex; + this.activeItem = inputs.activeItem; this.disabled = inputs.disabled; this.multiExpandable = inputs.multiExpandable; this.items = inputs.items; @@ -70,8 +70,7 @@ export class AccordionGroupPattern { } /** Inputs for the AccordionTriggerPattern. */ -export type AccordionTriggerInputs = ListNavigationItem & - ListFocusItem & +export type AccordionTriggerInputs = Omit & Omit & { /** A local unique identifier for the trigger. */ value: SignalLike; @@ -99,7 +98,7 @@ export class AccordionTriggerPattern { expansionControl: ExpansionControl; /** Whether the trigger is active. */ - active = computed(() => this.inputs.accordionGroup().focusManager.activeItem() === this); + active = computed(() => this.inputs.accordionGroup().activeItem() === this); /** Id of the accordion panel controlled by the trigger. */ controls = computed(() => this.inputs.accordionPanel()?.id()); @@ -110,6 +109,9 @@ export class AccordionTriggerPattern { /** Whether the trigger is disabled. Disabling an accordion group disables all the triggers. */ disabled = computed(() => this.inputs.disabled() || this.inputs.accordionGroup().disabled()); + /** The index of the trigger within its accordion group. */ + index = computed(() => this.inputs.accordionGroup().items().indexOf(this)); + constructor(readonly inputs: AccordionTriggerInputs) { this.id = inputs.id; this.element = inputs.element; 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 6e94e1164dd0..f115a3eed34d 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 @@ -18,12 +18,13 @@ type TestInputs = Partial> & { }; export function getListFocus(inputs: TestInputs = {}): ListFocus { + const items = inputs.items || getItems(inputs.numItems ?? 5); return new ListFocus({ - activeIndex: signal(0), + activeItem: signal(items()[0]), disabled: signal(false), skipDisabled: signal(false), focusMode: signal('roving'), - items: getItems(inputs.numItems ?? 5), + items: items, ...inputs, }); } @@ -35,6 +36,7 @@ function getItems(length: number): Signal { id: signal(`${i}`), disabled: signal(false), element: signal({focus: () => {}} as HTMLElement), + index: signal(i), }; }), ); @@ -58,7 +60,7 @@ describe('List Focus', () => { it('should set the tabindex based on the active index', () => { const items = focusManager.inputs.items() as TestItem[]; - focusManager.inputs.activeIndex.set(2); + focusManager.inputs.activeItem.set(focusManager.inputs.items()[2]); expect(focusManager.getItemTabindex(items[0])).toBe(-1); expect(focusManager.getItemTabindex(items[1])).toBe(-1); expect(focusManager.getItemTabindex(items[2])).toBe(0); @@ -84,7 +86,7 @@ describe('List Focus', () => { it('should set the tabindex of all items to -1', () => { const items = focusManager.inputs.items() as TestItem[]; - focusManager.inputs.activeIndex.set(0); + focusManager.inputs.activeItem.set(focusManager.inputs.items()[0]); expect(focusManager.getItemTabindex(items[0])).toBe(-1); expect(focusManager.getItemTabindex(items[1])).toBe(-1); expect(focusManager.getItemTabindex(items[2])).toBe(-1); @@ -93,7 +95,7 @@ describe('List Focus', () => { }); it('should update the activedescendant of the list when navigating', () => { - focusManager.inputs.activeIndex.set(1); + focusManager.inputs.activeItem.set(focusManager.inputs.items()[1]); expect(focusManager.getActiveDescendant()).toBe(focusManager.inputs.items()[1].id()); }); }); 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 7f9f3e3e042f..6da57eeb3b8b 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 @@ -19,6 +19,9 @@ export interface ListFocusItem { /** Whether an item is disabled. */ disabled: SignalLike; + + /** The index of the item in the list. */ + index: SignalLike; } /** Represents the required inputs for a collection that contains focusable items. */ @@ -32,8 +35,8 @@ export interface ListFocusInputs { /** The items in the list. */ items: SignalLike; - /** The index of the current active item. */ - activeIndex: WritableSignalLike; + /** The active item. */ + activeItem: WritableSignalLike; /** Whether disabled items in the list should be skipped when navigating. */ skipDisabled: SignalLike; @@ -41,11 +44,14 @@ export interface ListFocusInputs { /** Controls focus for a list of items. */ export class ListFocus { - /** The last index that was active. */ - prevActiveIndex = signal(0); + /** The last item that was active. */ + prevActiveItem = signal(undefined); + + /** The index of the last item that was active. */ + prevActiveIndex = computed(() => this.prevActiveItem()?.index() ?? -1); - /** The current active item. */ - activeItem = computed(() => this.inputs.items()[this.inputs.activeIndex()]); + /** The current active index in the list. */ + activeIndex = computed(() => this.inputs.activeItem()?.index() ?? -1); constructor(readonly inputs: ListFocusInputs) {} @@ -62,7 +68,7 @@ export class ListFocus { if (this.inputs.focusMode() === 'roving') { return undefined; } - return this.inputs.items()[this.inputs.activeIndex()].id(); + return this.inputs.activeItem()?.id() ?? undefined; } /** The tabindex for the list. */ @@ -81,7 +87,7 @@ export class ListFocus { if (this.inputs.focusMode() === 'activedescendant') { return -1; } - return this.activeItem() === item ? 0 : -1; + return this.inputs.activeItem() === item ? 0 : -1; } /** Moves focus to the given item if it is focusable. */ @@ -90,9 +96,8 @@ export class ListFocus { return false; } - this.prevActiveIndex.set(this.inputs.activeIndex()); - const index = this.inputs.items().indexOf(item); - this.inputs.activeIndex.set(index); + this.prevActiveItem.set(this.inputs.activeItem()); + this.inputs.activeItem.set(item); if (this.inputs.focusMode() === 'roving') { item.element().focus(); 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 253cb54450b9..dd02a5b3e920 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 @@ -33,9 +33,9 @@ describe('List Navigation', () => { describe('#goto', () => { it('should navigate to an item', () => { const nav = getNavigation(); - expect(nav.inputs.activeIndex()).toBe(0); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); nav.goto(nav.inputs.items()[3]); - expect(nav.inputs.activeIndex()).toBe(3); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[3]); }); }); @@ -43,7 +43,7 @@ describe('List Navigation', () => { it('should navigate next', () => { const nav = getNavigation(); nav.next(); // 0 -> 1 - expect(nav.inputs.activeIndex()).toBe(1); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[1]); }); it('should wrap', () => { @@ -53,7 +53,7 @@ describe('List Navigation', () => { nav.next(); // 2 -> 3 nav.next(); // 3 -> 4 nav.next(); // 4 -> 0 - expect(nav.inputs.activeIndex()).toBe(0); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); }); it('should not wrap', () => { @@ -63,7 +63,7 @@ describe('List Navigation', () => { nav.next(); // 2 -> 3 nav.next(); // 3 -> 4 nav.next(); // 4 -> 4 - expect(nav.inputs.activeIndex()).toBe(4); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[4]); }); it('should skip disabled items', () => { @@ -71,7 +71,7 @@ describe('List Navigation', () => { const items = nav.inputs.items() as TestItem[]; items[1].disabled.set(true); nav.next(); // 0 -> 2 - expect(nav.inputs.activeIndex()).toBe(2); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[2]); }); it('should not skip disabled items', () => { @@ -79,7 +79,7 @@ describe('List Navigation', () => { const items = nav.inputs.items() as TestItem[]; items[1].disabled.set(true); nav.next(); // 0 -> 1 - expect(nav.inputs.activeIndex()).toBe(1); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[1]); }); it('should wrap and skip disabled items', () => { @@ -95,7 +95,7 @@ describe('List Navigation', () => { nav.next(); // 0 -> 1 nav.next(); // 1 -> 0 - expect(nav.inputs.activeIndex()).toBe(0); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); }); it('should do nothing if other items are disabled', () => { @@ -106,68 +106,65 @@ describe('List Navigation', () => { items[3].disabled.set(true); items[4].disabled.set(true); nav.next(); // 0 -> 0 - expect(nav.inputs.activeIndex()).toBe(0); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); }); it('should do nothing if there are no other items to navigate to', () => { const nav = getNavigation({numItems: 1}); nav.next(); // 0 -> 0 - expect(nav.inputs.activeIndex()).toBe(0); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); }); }); describe('#prev', () => { it('should navigate prev', () => { - const nav = getNavigation({activeIndex: signal(2)}); + const nav = getNavigation(); + nav.goto(nav.inputs.items()[2]); nav.prev(); // 2 -> 1 - expect(nav.inputs.activeIndex()).toBe(1); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[1]); }); it('should wrap', () => { const nav = getNavigation({wrap: signal(true)}); nav.prev(); // 0 -> 4 - expect(nav.inputs.activeIndex()).toBe(4); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[4]); }); it('should not wrap', () => { const nav = getNavigation({wrap: signal(false)}); nav.prev(); // 0 -> 0 - expect(nav.inputs.activeIndex()).toBe(0); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); }); it('should skip disabled items', () => { - const nav = getNavigation({ - activeIndex: signal(2), - skipDisabled: signal(true), - }); + const nav = getNavigation({skipDisabled: signal(true)}); + nav.goto(nav.inputs.items()[2]); const items = nav.inputs.items() as TestItem[]; items[1].disabled.set(true); nav.prev(); // 2 -> 0 - expect(nav.inputs.activeIndex()).toBe(0); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); }); it('should not skip disabled items', () => { - const nav = getNavigation({ - activeIndex: signal(2), - skipDisabled: signal(false), - }); + const nav = getNavigation({skipDisabled: signal(false)}); + nav.goto(nav.inputs.items()[2]); const items = nav.inputs.items() as TestItem[]; items[1].disabled.set(true); nav.prev(); // 2 -> 1 - expect(nav.inputs.activeIndex()).toBe(1); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[1]); }); it('should wrap and skip disabled items', () => { const nav = getNavigation({ wrap: signal(true), - activeIndex: signal(2), skipDisabled: signal(true), }); + nav.goto(nav.inputs.items()[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); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[4]); }); it('should do nothing if other items are disabled', () => { @@ -180,43 +177,40 @@ describe('List Navigation', () => { items[3].disabled.set(true); items[4].disabled.set(true); nav.prev(); // 0 -> 0 - expect(nav.inputs.activeIndex()).toBe(0); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); }); it('should do nothing if there are no other items to navigate to', () => { const nav = getNavigation({numItems: 1}); nav.prev(); // 0 -> 0 - expect(nav.inputs.activeIndex()).toBe(0); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); }); }); describe('#first', () => { it('should navigate to the first item', () => { - const nav = getNavigation({activeIndex: signal(2)}); + const nav = getNavigation(); + nav.goto(nav.inputs.items()[2]); nav.first(); - expect(nav.inputs.activeIndex()).toBe(0); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); }); it('should skip disabled items', () => { - const nav = getNavigation({ - activeIndex: signal(2), - skipDisabled: signal(true), - }); + const nav = getNavigation({skipDisabled: signal(true)}); + nav.goto(nav.inputs.items()[2]); const items = nav.inputs.items() as TestItem[]; items[0].disabled.set(true); nav.first(); - expect(nav.inputs.activeIndex()).toBe(1); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[1]); }); it('should not skip disabled items', () => { - const nav = getNavigation({ - activeIndex: signal(2), - skipDisabled: signal(false), - }); + const nav = getNavigation({skipDisabled: signal(false)}); + nav.goto(nav.inputs.items()[2]); const items = nav.inputs.items() as TestItem[]; items[0].disabled.set(true); nav.first(); - expect(nav.inputs.activeIndex()).toBe(0); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[0]); }); }); @@ -224,7 +218,7 @@ describe('List Navigation', () => { it('should navigate to the last item', () => { const nav = getNavigation(); nav.last(); - expect(nav.inputs.activeIndex()).toBe(4); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[4]); }); it('should skip disabled items', () => { @@ -234,7 +228,7 @@ describe('List Navigation', () => { const items = nav.inputs.items() as TestItem[]; items[4].disabled.set(true); nav.last(); - expect(nav.inputs.activeIndex()).toBe(3); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[3]); }); it('should not skip disabled items', () => { @@ -244,7 +238,7 @@ describe('List Navigation', () => { const items = nav.inputs.items() as TestItem[]; items[4].disabled.set(true); nav.last(); - expect(nav.inputs.activeIndex()).toBe(4); + expect(nav.inputs.activeItem()).toBe(nav.inputs.items()[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 40e51bdb9742..57d576789cdb 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 @@ -64,7 +64,7 @@ export class ListNavigation { private _advance(delta: 1 | -1): boolean { const items = this.inputs.items(); const itemCount = items.length; - const startIndex = this.inputs.activeIndex(); + const startIndex = this.inputs.focusManager.activeIndex(); const step = (i: number) => this.inputs.wrap() ? (i + delta + itemCount) % itemCount : i + delta; 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 aab64dbf3970..65336685ea1e 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 @@ -41,6 +41,7 @@ function getItems(length: number): Signal { id: signal(`${i}`), disabled: signal(false), element: signal({focus: () => {}} as HTMLElement), + index: signal(i), }; }), ); @@ -260,11 +261,9 @@ describe('List Selection', () => { }); it('should select all items from an anchor at a higher index', () => { - const selection = getSelection({ - multi: signal(true), - activeIndex: signal(3), - }); + const selection = getSelection({multi: signal(true)}); const items = selection.inputs.items() as TestItem[]; + selection.inputs.activeItem.set(items[3]); selection.select(); // [3] selection.inputs.focusManager.focus(items[1]); @@ -274,11 +273,9 @@ describe('List Selection', () => { }); it('should deselect items within the range when the range is changed', () => { - const selection = getSelection({ - multi: signal(true), - activeIndex: signal(2), - }); + const selection = getSelection({multi: signal(true)}); const items = selection.inputs.items() as TestItem[]; + selection.inputs.activeItem.set(items[2]); selection.select(); // [2] expect(selection.inputs.value()).toEqual([2]); @@ -313,11 +310,10 @@ describe('List Selection', () => { const selection = getSelection({multi: signal(true)}); const items = selection.inputs.items() as TestItem[]; - selection.select(items[1]); + selection.select(items[1]); // [1] items[1].disabled.set(true); - selection.select(); // [0] - selection.inputs.focusManager.focus(items[0]); + selection.select(); // [0, 1] expect(selection.inputs.value()).toEqual([1, 0]); selection.inputs.focusManager.focus(items[2]); @@ -325,7 +321,7 @@ describe('List Selection', () => { expect(selection.inputs.value()).toEqual([1, 0, 2]); selection.inputs.focusManager.focus(items[0]); - selection.selectRange(); // [0] + selection.selectRange(); // [0, 1] expect(selection.inputs.value()).toEqual([1, 0]); }); }); 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 339759cd3f93..b823b7d924eb 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 @@ -45,7 +45,7 @@ export class ListSelection, V> { /** Selects the item at the current active index. */ select(item?: ListSelectionItem, opts = {anchor: true}) { - item = item ?? (this.inputs.focusManager.activeItem() as ListSelectionItem); + item = item ?? (this.inputs.focusManager.inputs.activeItem() as ListSelectionItem); if (item.disabled() || this.inputs.value().includes(item.value())) { return; @@ -63,24 +63,28 @@ export class ListSelection, V> { } /** Deselects the item at the current active index. */ - deselect(item?: T) { - item = item ?? this.inputs.focusManager.activeItem(); + deselect(item?: T | null) { + item = item ?? this.inputs.focusManager.inputs.activeItem(); - if (!item.disabled()) { + if (item && !item.disabled()) { this.inputs.value.update(values => values.filter(value => value !== item.value())); } } /** Toggles the item at the current active index. */ toggle() { - const item = this.inputs.focusManager.activeItem(); - this.inputs.value().includes(item.value()) ? this.deselect() : this.select(); + const item = this.inputs.focusManager.inputs.activeItem(); + if (item) { + this.inputs.value().includes(item.value()) ? this.deselect() : this.select(); + } } /** Toggles only the item at the current active index. */ toggleOne() { - const item = this.inputs.focusManager.activeItem(); - this.inputs.value().includes(item.value()) ? this.deselect() : this.selectOne(); + const item = this.inputs.focusManager.inputs.activeItem(); + if (item) { + this.inputs.value().includes(item.value()) ? this.deselect() : this.selectOne(); + } } /** Selects all items in the list. */ @@ -120,7 +124,8 @@ export class ListSelection, V> { /** Sets the selection to only the current active item. */ selectOne() { - if (this.inputs.focusManager.activeItem().disabled()) { + const item = this.inputs.focusManager.inputs.activeItem(); + if (item && item.disabled()) { return; } @@ -167,7 +172,7 @@ export class ListSelection, V> { } /** Marks the given index as the start of a range selection. */ - beginRangeSelection(index: number = this.inputs.activeIndex()) { + beginRangeSelection(index: number = this.inputs.focusManager.activeIndex()) { this.rangeStartIndex.set(index); this.rangeEndIndex.set(index); } @@ -178,15 +183,15 @@ export class ListSelection, V> { return []; } - const upper = Math.max(this.inputs.activeIndex(), index); - const lower = Math.min(this.inputs.activeIndex(), index); + const upper = Math.max(this.inputs.focusManager.activeIndex(), index); + const lower = Math.min(this.inputs.focusManager.activeIndex(), index); const items = []; for (let i = lower; i <= upper; i++) { items.push(this.inputs.items()[i]); } - if (this.inputs.activeIndex() < index) { + if (this.inputs.focusManager.activeIndex() < index) { return items.reverse(); } 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 421c2a7de6ee..34c57899e4ca 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 @@ -36,6 +36,7 @@ function getItems(length: number): Signal { return signal( Array.from({length}).map((_, i) => { return { + index: signal(i), searchTerm: signal(`Item ${i}`), id: signal(`${i}`), disabled: signal(false), @@ -57,38 +58,38 @@ describe('List Typeahead', () => { describe('#search', () => { it('should navigate to an item', () => { typeahead.search('i'); - expect(typeahead.inputs.activeIndex()).toBe(1); + expect(typeahead.inputs.focusManager.activeIndex()).toBe(1); typeahead.search('t'); typeahead.search('e'); typeahead.search('m'); typeahead.search(' '); typeahead.search('3'); - expect(typeahead.inputs.activeIndex()).toBe(3); + expect(typeahead.inputs.focusManager.activeIndex()).toBe(3); }); it('should reset after a delay', fakeAsync(() => { typeahead.search('i'); - expect(typeahead.inputs.activeIndex()).toBe(1); + expect(typeahead.inputs.focusManager.activeIndex()).toBe(1); tick(500); typeahead.search('i'); - expect(typeahead.inputs.activeIndex()).toBe(2); + expect(typeahead.inputs.focusManager.activeIndex()).toBe(2); })); it('should skip disabled items', () => { items[1].disabled.set(true); (typeahead.inputs.skipDisabled as WritableSignal).set(true); typeahead.search('i'); - expect(typeahead.inputs.activeIndex()).toBe(2); + expect(typeahead.inputs.focusManager.activeIndex()).toBe(2); }); it('should not skip disabled items', () => { items[1].disabled.set(true); (typeahead.inputs.skipDisabled as WritableSignal).set(false); typeahead.search('i'); - expect(typeahead.inputs.activeIndex()).toBe(1); + expect(typeahead.inputs.focusManager.activeIndex()).toBe(1); }); it('should ignore keys like shift', () => { @@ -101,7 +102,7 @@ describe('List Typeahead', () => { typeahead.search('m'); typeahead.search(' '); typeahead.search('2'); - expect(typeahead.inputs.activeIndex()).toBe(2); + expect(typeahead.inputs.focusManager.activeIndex()).toBe(2); }); it('should not allow a query to begin with a space', () => { @@ -112,7 +113,7 @@ describe('List Typeahead', () => { typeahead.search('m'); typeahead.search(' '); typeahead.search('3'); - expect(typeahead.inputs.activeIndex()).toBe(3); + expect(typeahead.inputs.focusManager.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 b1e717a7ec51..3ee7bfe3a7a0 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 @@ -60,7 +60,7 @@ export class ListTypeahead { } if (this._startIndex() === undefined) { - this._startIndex.set(this.focusManager.inputs.activeIndex()); + this._startIndex.set(this.focusManager.activeIndex()); } clearTimeout(this.timeout); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list/list.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list/list.spec.ts index aa7dface99df..06f234cb42b9 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list/list.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list/list.spec.ts @@ -23,7 +23,7 @@ describe('List Behavior', () => { function getList(inputs: Partial> & Pick, 'items'>): TestList { return new List({ value: inputs.value ?? signal([]), - activeIndex: inputs.activeIndex ?? signal(0), + activeItem: signal(undefined), typeaheadDelay: inputs.typeaheadDelay ?? signal(0.5), wrap: inputs.wrap ?? signal(true), disabled: inputs.disabled ?? signal(false), @@ -44,6 +44,7 @@ describe('List Behavior', () => { element: signal(document.createElement('div')), disabled: signal(false), searchTerm: signal(String(value)), + index: signal(index), })); } @@ -51,6 +52,7 @@ describe('List Behavior', () => { const items = signal[]>([]); const list = getList({...inputs, items}); items.set(getItems(values)); + list.inputs.activeItem.set(list.inputs.items()[0]); return {list, items: items()}; } @@ -124,11 +126,11 @@ describe('List Behavior', () => { }); it('should not change active index on navigation', () => { - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); list.next(); - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); list.last(); - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); }); it('should not select items', () => { @@ -144,70 +146,72 @@ describe('List Behavior', () => { describe('Navigation', () => { it('should navigate to the next item with next()', () => { const {list} = getDefaultPatterns(); - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); list.next(); - expect(list.inputs.activeIndex()).toBe(1); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[1]); }); it('should navigate to the previous item with prev()', () => { - const {list} = getDefaultPatterns({activeIndex: signal(1)}); - expect(list.inputs.activeIndex()).toBe(1); + const {list, items} = getDefaultPatterns(); + list.inputs.activeItem.set(items[1]); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[1]); list.prev(); - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); }); it('should navigate to the first item with first()', () => { - const {list} = getDefaultPatterns({activeIndex: signal(8)}); - expect(list.inputs.activeIndex()).toBe(8); + const {list, items} = getDefaultPatterns(); + list.inputs.activeItem.set(items[8]); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[8]); list.first(); - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); }); it('should navigate to the last item with last()', () => { const {list} = getDefaultPatterns(); - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); list.last(); - expect(list.inputs.activeIndex()).toBe(8); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[8]); }); it('should skip disabled items when navigating', () => { const {list, items} = getDefaultPatterns(); items[1].disabled.set(true); // Disable second item - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); list.next(); - expect(list.inputs.activeIndex()).toBe(2); // Should skip to 'Banana' + expect(list.inputs.activeItem()).toBe(list.inputs.items()[2]); // Should skip to 'Banana' list.prev(); - expect(list.inputs.activeIndex()).toBe(0); // Should skip back to 'Apple' + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); // Should skip back to 'Apple' }); it('should not skip disabled items when skipDisabled is false', () => { const {list, items} = getDefaultPatterns({skipDisabled: signal(false)}); items[1].disabled.set(true); // Disable second item - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); list.next(); - expect(list.inputs.activeIndex()).toBe(1); // Should land on second item even though it's disabled + expect(list.inputs.activeItem()).toBe(list.inputs.items()[1]); // Should land on second item even though it's disabled }); it('should not wrap with wrap: false', () => { const {list} = getDefaultPatterns({wrap: signal(false)}); list.last(); - expect(list.inputs.activeIndex()).toBe(8); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[8]); list.next(); - expect(list.inputs.activeIndex()).toBe(8); // Stays at the end + expect(list.inputs.activeItem()).toBe(list.inputs.items()[8]); // Stays at the end list.first(); - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); list.prev(); - expect(list.inputs.activeIndex()).toBe(0); // Stays at the beginning + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); // Stays at the beginning }); // The navigation behavior itself doesn't change for horizontal, but we test it for completeness. it('should navigate with orientation: "horizontal"', () => { const {list} = getDefaultPatterns({orientation: signal('horizontal')}); - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); list.next(); - expect(list.inputs.activeIndex()).toBe(1); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[1]); list.prev(); - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); }); }); @@ -295,7 +299,7 @@ describe('List Behavior', () => { it('should not wrap when range selecting', () => { list.anchor(0); list.prev({selectRange: true}); - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); expect(list.inputs.value()).toEqual([]); }); @@ -311,30 +315,30 @@ describe('List Behavior', () => { describe('Typeahead', () => { it('should navigate to an item via typeahead', fakeAsync(() => { const {list} = getDefaultPatterns(); - expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.activeItem()).toBe(list.inputs.items()[0]); list.search('b'); - expect(list.inputs.activeIndex()).toBe(2); // Banana + expect(list.inputs.activeItem()).toBe(list.inputs.items()[2]); // Banana list.search('l'); - expect(list.inputs.activeIndex()).toBe(3); // Blackberry + expect(list.inputs.activeItem()).toBe(list.inputs.items()[3]); // Blackberry list.search('u'); - expect(list.inputs.activeIndex()).toBe(4); // Blueberry + expect(list.inputs.activeItem()).toBe(list.inputs.items()[4]); // Blueberry tick(500); // Default delay list.search('c'); - expect(list.inputs.activeIndex()).toBe(5); // Cantaloupe + expect(list.inputs.activeItem()).toBe(list.inputs.items()[5]); // Cantaloupe })); it('should respect typeaheadDelay', fakeAsync(() => { const {list} = getDefaultPatterns({typeaheadDelay: signal(0.1)}); list.search('b'); - expect(list.inputs.activeIndex()).toBe(2); // Banana + expect(list.inputs.activeItem()).toBe(list.inputs.items()[2]); // Banana tick(50); // Less than delay list.search('l'); - expect(list.inputs.activeIndex()).toBe(3); // Blackberry + expect(list.inputs.activeItem()).toBe(list.inputs.items()[3]); // Blackberry tick(101); // More than delay list.search('c'); - expect(list.inputs.activeIndex()).toBe(5); // Cantaloupe + expect(list.inputs.activeItem()).toBe(list.inputs.items()[5]); // Cantaloupe })); it('should select an item via typeahead', () => { diff --git a/src/cdk-experimental/ui-patterns/behaviors/list/list.ts b/src/cdk-experimental/ui-patterns/behaviors/list/list.ts index 66f341538d27..b80f9730bd9a 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/list/list.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/list/list.ts @@ -68,8 +68,8 @@ export class List, V> { /** The tabindex of the list. */ tabindex = computed(() => this.focusBehavior.getListTabindex()); - /** The currently active item in the list. */ - activeItem = computed(() => this.focusBehavior.activeItem()); + /** The index of the currently active item in the list. */ + activeIndex = computed(() => this.focusBehavior.activeIndex()); /** * The uncommitted index for selecting a range of options. diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts index c29944b12f0c..33cd25335b6d 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts @@ -34,7 +34,7 @@ describe('Listbox Pattern', () => { return new ListboxPattern({ items: inputs.items, value: inputs.value ?? signal([]), - activeIndex: inputs.activeIndex ?? signal(0), + activeItem: signal(undefined), typeaheadDelay: inputs.typeaheadDelay ?? signal(0.5), wrap: inputs.wrap ?? signal(true), readonly: inputs.readonly ?? signal(false), @@ -67,6 +67,7 @@ describe('Listbox Pattern', () => { const options = signal([]); const listbox = getListbox({...inputs, items: options}); options.set(getOptions(listbox, values)); + listbox.inputs.activeItem.set(options()[0]); return {listbox, options: options()}; } @@ -90,33 +91,32 @@ describe('Listbox Pattern', () => { describe('Keyboard Navigation', () => { it('should navigate next on ArrowDown', () => { const {listbox} = getDefaultPatterns(); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); listbox.onKeydown(down()); - expect(listbox.inputs.activeIndex()).toBe(1); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); }); it('should navigate prev on ArrowUp', () => { - const {listbox} = getDefaultPatterns({activeIndex: signal(1)}); - expect(listbox.inputs.activeIndex()).toBe(1); + const {listbox, options} = getDefaultPatterns(); + listbox.inputs.activeItem.set(options[1]); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); listbox.onKeydown(up()); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); }); it('should navigate next on ArrowRight (horizontal)', () => { const {listbox} = getDefaultPatterns({orientation: signal('horizontal')}); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); listbox.onKeydown(right()); - expect(listbox.inputs.activeIndex()).toBe(1); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); }); it('should navigate prev on ArrowLeft (horizontal)', () => { - const {listbox} = getDefaultPatterns({ - activeIndex: signal(1), - orientation: signal('horizontal'), - }); - expect(listbox.inputs.activeIndex()).toBe(1); + const {listbox, options} = getDefaultPatterns({orientation: signal('horizontal')}); + listbox.inputs.activeItem.set(options[1]); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); listbox.onKeydown(left()); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); }); it('should navigate next on ArrowLeft (horizontal & rtl)', () => { @@ -124,48 +124,47 @@ describe('Listbox Pattern', () => { textDirection: signal('rtl'), orientation: signal('horizontal'), }); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); listbox.onKeydown(left()); - expect(listbox.inputs.activeIndex()).toBe(1); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); }); it('should navigate prev on ArrowRight (horizontal & rtl)', () => { - const {listbox} = getDefaultPatterns({ - activeIndex: signal(1), + const {listbox, options} = getDefaultPatterns({ textDirection: signal('rtl'), orientation: signal('horizontal'), }); - expect(listbox.inputs.activeIndex()).toBe(1); + listbox.inputs.activeItem.set(options[1]); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); listbox.onKeydown(right()); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); }); it('should navigate to the first option on Home', () => { - const {listbox} = getDefaultPatterns({ - activeIndex: signal(8), - }); - expect(listbox.inputs.activeIndex()).toBe(8); + const {listbox, options} = getDefaultPatterns(); + listbox.inputs.activeItem.set(options[8]); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[8]); listbox.onKeydown(home()); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); }); it('should navigate to the last option on End', () => { const {listbox} = getDefaultPatterns(); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); listbox.onKeydown(end()); - expect(listbox.inputs.activeIndex()).toBe(8); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[8]); }); it('should be able to navigate in readonly mode', () => { const {listbox} = getDefaultPatterns(); listbox.onKeydown(down()); - expect(listbox.inputs.activeIndex()).toBe(1); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); listbox.onKeydown(up()); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); listbox.onKeydown(end()); - expect(listbox.inputs.activeIndex()).toBe(8); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[8]); listbox.onKeydown(home()); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); }); }); @@ -178,23 +177,23 @@ describe('Listbox Pattern', () => { selectionMode: signal('follow'), }); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); listbox.onKeydown(down()); - expect(listbox.inputs.activeIndex()).toBe(1); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); expect(listbox.inputs.value()).toEqual(['Apricot']); listbox.onKeydown(up()); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); listbox.onKeydown(end()); - expect(listbox.inputs.activeIndex()).toBe(8); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[8]); expect(listbox.inputs.value()).toEqual(['Cranberry']); listbox.onKeydown(home()); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); }); @@ -206,11 +205,11 @@ describe('Listbox Pattern', () => { selectionMode: signal('follow'), }); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); expect(listbox.inputs.value()).toEqual(['Apple']); listbox.onKeydown(down()); - expect(listbox.inputs.activeIndex()).toBe(1); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); expect(listbox.inputs.value()).toEqual(['Apple']); }); }); @@ -723,7 +722,7 @@ describe('Listbox Pattern', () => { listbox.onKeydown(shift()); listbox.onPointerdown(click(options, 2, {shift: true})); expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']); - expect(listbox.inputs.activeIndex()).toEqual(2); + expect(listbox.inputs.activeItem()).toEqual(options[2]); }); it('should do nothing on click if the option is disabled', () => { @@ -790,7 +789,7 @@ describe('Listbox Pattern', () => { it('should set the active index to the first option', () => { const {listbox} = getDefaultPatterns(); listbox.setDefaultState(); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); }); it('should set the active index to the first focusable option', () => { @@ -799,7 +798,7 @@ describe('Listbox Pattern', () => { }); options[0].disabled.set(true); listbox.setDefaultState(); - expect(listbox.inputs.activeIndex()).toBe(1); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[1]); }); it('should set the active index to the first selected option', () => { @@ -808,7 +807,7 @@ describe('Listbox Pattern', () => { skipDisabled: signal(true), }); listbox.setDefaultState(); - expect(listbox.inputs.activeIndex()).toBe(2); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[2]); }); it('should set the active index to the first focusable selected option', () => { @@ -818,7 +817,7 @@ describe('Listbox Pattern', () => { }); options[2].disabled.set(true); listbox.setDefaultState(); - expect(listbox.inputs.activeIndex()).toBe(3); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[3]); }); it('should set the active index to the first option if no selected option is focusable', () => { @@ -828,7 +827,7 @@ describe('Listbox Pattern', () => { }); options[2].disabled.set(true); listbox.setDefaultState(); - expect(listbox.inputs.activeIndex()).toBe(0); + expect(listbox.inputs.activeItem()).toBe(listbox.inputs.items()[0]); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 10237dce5e5f..893095f070af 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -103,7 +103,7 @@ export class ListboxPattern { if (this.inputs.multi()) { manager - .on(Modifier.Any, 'Shift', () => this.listBehavior.anchor(this.inputs.activeIndex())) + .on(Modifier.Any, 'Shift', () => this.listBehavior.anchor(this.listBehavior.activeIndex())) .on(Modifier.Shift, this.prevKey, () => this.listBehavior.prev({selectRange: true})) .on(Modifier.Shift, this.nextKey, () => this.listBehavior.next({selectRange: true})) .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => @@ -202,12 +202,11 @@ export class ListboxPattern { ); } - if ( - this.inputs.items.length && - (this.inputs.activeIndex() < 0 || this.inputs.activeIndex() >= this.inputs.items().length) - ) { + const activeItem = this.inputs.activeItem(); + + if (activeItem && !this.inputs.items().includes(activeItem)) { violations.push( - `The active index is out of bounds. Number of options: ${this.inputs.items().length} Active index: ${this.inputs.activeIndex()}.`, + `The current active item does not exist in the list. Active item: ${activeItem.id()}.`, ); } @@ -246,14 +245,14 @@ export class ListboxPattern { firstItem = item; } if (item.selected()) { - this.inputs.activeIndex.set(item.index()); + this.inputs.activeItem.set(item); return; } } } if (firstItem) { - this.inputs.activeIndex.set(firstItem.index()); + this.inputs.activeItem.set(firstItem); } } diff --git a/src/cdk-experimental/ui-patterns/listbox/option.ts b/src/cdk-experimental/ui-patterns/listbox/option.ts index b8984ac8889c..77d65184c1cc 100644 --- a/src/cdk-experimental/ui-patterns/listbox/option.ts +++ b/src/cdk-experimental/ui-patterns/listbox/option.ts @@ -20,7 +20,7 @@ interface ListboxPattern { } /** Represents the required inputs for an option in a listbox. */ -export interface OptionInputs extends ListItem { +export interface OptionInputs extends Omit, 'index'> { listbox: SignalLike | undefined>; } @@ -33,15 +33,10 @@ export class OptionPattern { value: SignalLike; /** The position of the option in the list. */ - index = computed( - () => - this.listbox() - ?.inputs.items() - .findIndex(i => i.id() === this.id()) ?? -1, - ); + index = computed(() => this.listbox()?.inputs.items().indexOf(this) ?? -1); /** Whether the option is active. */ - active = computed(() => this.listbox()?.listBehavior.activeItem() === this); + active = computed(() => this.listbox()?.inputs.activeItem() === this); /** Whether the option is selected. */ selected = computed(() => this.listbox()?.inputs.value().includes(this.value())); diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts index 528b94d51b90..8d3e1f1b5fce 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts @@ -20,7 +20,7 @@ interface RadioGroupLike { } /** Represents the required inputs for a radio button in a radio group. */ -export interface RadioButtonInputs extends Omit, 'searchTerm'> { +export interface RadioButtonInputs extends Omit, 'searchTerm' | 'index'> { /** A reference to the parent radio group. */ group: SignalLike | undefined>; } @@ -34,15 +34,10 @@ export class RadioButtonPattern { value: SignalLike; /** The position of the radio button within the group. */ - index = computed( - () => - this.group() - ?.listBehavior.inputs.items() - .findIndex(i => i.id() === this.id()) ?? -1, - ); + index = computed(() => this.group()?.listBehavior.inputs.items().indexOf(this) ?? -1); /** Whether the radio button is currently the active one (focused). */ - active = computed(() => this.group()?.listBehavior.activeItem() === this); + active = computed(() => this.group()?.listBehavior.inputs.activeItem() === this); /** Whether the radio button is selected. */ selected: SignalLike = computed( diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts index 1029f35597aa..6945034909b8 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -141,14 +141,14 @@ export class RadioGroupPattern { firstItem = item; } if (item.selected()) { - this.inputs.activeIndex.set(item.index()); + this.inputs.activeItem.set(item); return; } } } if (firstItem) { - this.inputs.activeIndex.set(firstItem.index()); + this.inputs.activeItem.set(firstItem); } } diff --git a/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts index beed688c0ded..2aed6abfaa1f 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio.spec.ts @@ -32,7 +32,7 @@ describe('RadioGroup Pattern', () => { return new RadioGroupPattern({ items: inputs.items, value: inputs.value ?? signal([]), - activeIndex: inputs.activeIndex ?? signal(0), + activeItem: signal(undefined), readonly: inputs.readonly ?? signal(false), disabled: inputs.disabled ?? signal(false), skipDisabled: inputs.skipDisabled ?? signal(true), @@ -60,6 +60,7 @@ describe('RadioGroup Pattern', () => { const radioButtons = signal([]); const radioGroup = getRadioGroup({...inputs, items: radioButtons}); radioButtons.set(getRadios(radioGroup, values)); + radioGroup.inputs.activeItem.set(radioButtons()[0]); return {radioGroup, radioButtons: radioButtons()}; } @@ -69,101 +70,100 @@ describe('RadioGroup Pattern', () => { describe('Keyboard Navigation', () => { it('should navigate next on ArrowDown', () => { - const {radioGroup} = getDefaultPatterns(); - expect(radioGroup.inputs.activeIndex()).toBe(0); + const {radioGroup, radioButtons} = getDefaultPatterns(); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); radioGroup.onKeydown(down()); - expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); }); it('should navigate prev on ArrowUp', () => { - const {radioGroup} = getDefaultPatterns({activeIndex: signal(1)}); - expect(radioGroup.inputs.activeIndex()).toBe(1); + const {radioGroup, radioButtons} = getDefaultPatterns(); + radioGroup.inputs.activeItem.set(radioButtons[1]); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); radioGroup.onKeydown(up()); - expect(radioGroup.inputs.activeIndex()).toBe(0); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); }); it('should navigate next on ArrowRight (horizontal)', () => { - const {radioGroup} = getDefaultPatterns({orientation: signal('horizontal')}); - expect(radioGroup.inputs.activeIndex()).toBe(0); + const {radioGroup, radioButtons} = getDefaultPatterns({orientation: signal('horizontal')}); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); radioGroup.onKeydown(right()); - expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); }); it('should navigate prev on ArrowLeft (horizontal)', () => { - const {radioGroup} = getDefaultPatterns({ - activeIndex: signal(1), - orientation: signal('horizontal'), - }); - expect(radioGroup.inputs.activeIndex()).toBe(1); + const {radioGroup, radioButtons} = getDefaultPatterns({orientation: signal('horizontal')}); + radioGroup.inputs.activeItem.set(radioButtons[1]); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); radioGroup.onKeydown(left()); - expect(radioGroup.inputs.activeIndex()).toBe(0); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); }); it('should navigate next on ArrowLeft (horizontal & rtl)', () => { - const {radioGroup} = getDefaultPatterns({ + const {radioGroup, radioButtons} = getDefaultPatterns({ textDirection: signal('rtl'), orientation: signal('horizontal'), }); - expect(radioGroup.inputs.activeIndex()).toBe(0); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); radioGroup.onKeydown(left()); - expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); }); it('should navigate prev on ArrowRight (horizontal & rtl)', () => { - const {radioGroup} = getDefaultPatterns({ - activeIndex: signal(1), + const {radioGroup, radioButtons} = getDefaultPatterns({ textDirection: signal('rtl'), orientation: signal('horizontal'), }); - expect(radioGroup.inputs.activeIndex()).toBe(1); + radioGroup.inputs.activeItem.set(radioButtons[1]); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); radioGroup.onKeydown(right()); - expect(radioGroup.inputs.activeIndex()).toBe(0); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); }); it('should navigate to the first radio on Home', () => { - const {radioGroup} = getDefaultPatterns({ - activeIndex: signal(4), - }); - expect(radioGroup.inputs.activeIndex()).toBe(4); + const {radioGroup, radioButtons} = getDefaultPatterns(); + radioGroup.inputs.activeItem.set(radioButtons[4]); + + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[4]); radioGroup.onKeydown(home()); - expect(radioGroup.inputs.activeIndex()).toBe(0); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); }); it('should navigate to the last radio on End', () => { - const {radioGroup} = getDefaultPatterns(); - expect(radioGroup.inputs.activeIndex()).toBe(0); + const {radioGroup, radioButtons} = getDefaultPatterns(); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); radioGroup.onKeydown(end()); - expect(radioGroup.inputs.activeIndex()).toBe(4); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[4]); }); it('should skip disabled radios when skipDisabled is true', () => { const {radioGroup, radioButtons} = getDefaultPatterns({skipDisabled: signal(true)}); radioButtons[1].disabled.set(true); radioGroup.onKeydown(down()); - expect(radioGroup.inputs.activeIndex()).toBe(2); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[2]); radioGroup.onKeydown(up()); - expect(radioGroup.inputs.activeIndex()).toBe(0); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); }); it('should not skip disabled radios when skipDisabled is false', () => { const {radioGroup, radioButtons} = getDefaultPatterns({skipDisabled: signal(false)}); radioButtons[1].disabled.set(true); radioGroup.onKeydown(down()); - expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); radioGroup.onKeydown(up()); - expect(radioGroup.inputs.activeIndex()).toBe(0); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); }); it('should be able to navigate in readonly mode', () => { - const {radioGroup} = getDefaultPatterns({readonly: signal(true)}); + const {radioGroup, radioButtons} = getDefaultPatterns({readonly: signal(true)}); radioGroup.onKeydown(down()); - expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); radioGroup.onKeydown(up()); - expect(radioGroup.inputs.activeIndex()).toBe(0); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); radioGroup.onKeydown(end()); - expect(radioGroup.inputs.activeIndex()).toBe(4); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[4]); radioGroup.onKeydown(home()); - expect(radioGroup.inputs.activeIndex()).toBe(0); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); }); }); @@ -202,7 +202,7 @@ describe('RadioGroup Pattern', () => { expect(radioGroup.inputs.value()).toEqual([]); radioGroup.onKeydown(down()); // Navigation still works - expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.activeItem()).toBe(radioGroup.inputs.items()[1]); expect(radioGroup.inputs.value()).toEqual([]); // Selection doesn't change radioGroup.onKeydown(enter()); @@ -216,7 +216,7 @@ describe('RadioGroup Pattern', () => { radioButtons[1].disabled.set(true); radioGroup.onKeydown(down()); // Focus B (disabled) - expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); expect(radioGroup.inputs.value()).toEqual([]); // Should not select B radioGroup.onKeydown(space()); // Try selecting B with space @@ -226,7 +226,7 @@ describe('RadioGroup Pattern', () => { expect(radioGroup.inputs.value()).toEqual([]); radioGroup.onKeydown(down()); // Focus C - expect(radioGroup.inputs.activeIndex()).toBe(2); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[2]); expect(radioGroup.inputs.value()).toEqual(['C']); // Selects C on navigation }); }); @@ -242,7 +242,7 @@ describe('RadioGroup Pattern', () => { const {radioGroup, radioButtons} = getDefaultPatterns(); radioGroup.onPointerdown(click(radioButtons, 1)); expect(radioGroup.inputs.value()).toEqual(['Banana']); - expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); }); it('should not select a disabled radio on click', () => { @@ -250,52 +250,45 @@ describe('RadioGroup Pattern', () => { radioButtons[1].disabled.set(true); radioGroup.onPointerdown(click(radioButtons, 1)); expect(radioGroup.inputs.value()).toEqual([]); - expect(radioGroup.inputs.activeIndex()).toBe(0); // Active index shouldn't change + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); // Active index shouldn't change }); it('should only update active index when readonly', () => { const {radioGroup, radioButtons} = getDefaultPatterns({readonly: signal(true)}); radioGroup.onPointerdown(click(radioButtons, 1)); expect(radioGroup.inputs.value()).toEqual([]); - expect(radioGroup.inputs.activeIndex()).toBe(1); // Active index should update + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); // Active index should update }); }); describe('#setDefaultState', () => { it('should set the active index to the first radio', () => { - const {radioGroup} = getDefaultPatterns({activeIndex: signal(-1)}); + const {radioGroup, radioButtons} = getDefaultPatterns(); radioGroup.setDefaultState(); - expect(radioGroup.inputs.activeIndex()).toBe(0); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); }); it('should set the active index to the first focusable radio', () => { - const {radioGroup, radioButtons} = getDefaultPatterns({ - skipDisabled: signal(true), - activeIndex: signal(-1), - }); + const {radioGroup, radioButtons} = getDefaultPatterns({skipDisabled: signal(true)}); radioButtons[0].disabled.set(true); radioGroup.setDefaultState(); - expect(radioGroup.inputs.activeIndex()).toBe(1); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[1]); }); it('should set the active index to the selected radio', () => { - const {radioGroup} = getDefaultPatterns({ - value: signal(['Cherry']), - activeIndex: signal(-1), - }); + const {radioGroup, radioButtons} = getDefaultPatterns({value: signal(['Cherry'])}); radioGroup.setDefaultState(); - expect(radioGroup.inputs.activeIndex()).toBe(2); + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[2]); }); it('should set the active index to the first focusable radio if selected is disabled', () => { const {radioGroup, radioButtons} = getDefaultPatterns({ value: signal(['Cherry']), skipDisabled: signal(true), - activeIndex: signal(-1), }); radioButtons[2].disabled.set(true); // Disable Cherry radioGroup.setDefaultState(); - expect(radioGroup.inputs.activeIndex()).toBe(0); // Defaults to first focusable + expect(radioGroup.inputs.activeItem()).toBe(radioButtons[0]); // Defaults to first focusable }); }); diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts index 92cef42f6ab1..852fe3b5b0c3 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts @@ -62,7 +62,7 @@ describe('Tabs Pattern', () => { selectionMode: signal('follow'), focusMode: signal('roving'), disabled: signal(false), - activeIndex: signal(0), + activeItem: signal(undefined), skipDisabled: signal(true), items: signal([]), value: signal(['tab-1']), @@ -134,6 +134,7 @@ describe('Tabs Pattern', () => { tabPanelInputs[1].tab.set(tabPatterns[1]); tabPanelInputs[2].tab.set(tabPatterns[2]); tabListInputs.items.set(tabPatterns); + tabListInputs.activeItem.set(tabPatterns[0]); }); it('sets the selected tab by setting `value`.', () => { @@ -185,38 +186,38 @@ describe('Tabs Pattern', () => { describe('#setDefaultState', () => { it('should not set activeIndex if there are no tabs', () => { tabListInputs.items.set([]); - tabListInputs.activeIndex.set(10); + tabListInputs.activeItem.set(tabPatterns[10]); tabListPattern.setDefaultState(); - expect(tabListInputs.activeIndex()).toBe(10); + expect(tabListInputs.activeItem()).toBe(tabPatterns[10]); }); it('should not set activeIndex if no tabs are focusable', () => { tabInputs.forEach(input => input.disabled.set(true)); - tabListInputs.activeIndex.set(10); + tabListInputs.activeItem.set(tabPatterns[10]); tabListPattern.setDefaultState(); - expect(tabListInputs.activeIndex()).toBe(10); + expect(tabListInputs.activeItem()).toBe(tabPatterns[10]); }); it('should set activeIndex to the first focusable tab if no tabs are selected', () => { - tabListInputs.activeIndex.set(2); + tabListInputs.activeItem.set(tabPatterns[2]); tabListInputs.value.set([]); tabInputs[0].disabled.set(true); tabListPattern.setDefaultState(); - expect(tabListInputs.activeIndex()).toBe(1); + expect(tabListInputs.activeItem()).toBe(tabPatterns[1]); }); it('should set activeIndex to the first focusable and selected tab', () => { - tabListInputs.activeIndex.set(0); + tabListInputs.activeItem.set(tabPatterns[0]); tabListInputs.value.set([tabPatterns[2].value()]); tabListPattern.setDefaultState(); - expect(tabListInputs.activeIndex()).toBe(2); + expect(tabListInputs.activeItem()).toBe(tabPatterns[2]); }); it('should set activeIndex to the first focusable tab when the selected tab is not focusable', () => { tabListInputs.value.set([tabPatterns[1].value()]); tabInputs[1].disabled.set(true); tabListPattern.setDefaultState(); - expect(tabListInputs.activeIndex()).toBe(0); + expect(tabListInputs.activeItem()).toBe(tabPatterns[0]); }); }); @@ -278,14 +279,14 @@ describe('Tabs Pattern', () => { }); it('uses left key to navigate to the previous tab when `orientation` is set to "horizontal".', () => { - tabListInputs.activeIndex.set(1); + tabListInputs.activeItem.set(tabPatterns[1]); expect(tabPatterns[1].active()).toBeTrue(); tabListPattern.onKeydown(left()); expect(tabPatterns[0].active()).toBeTrue(); }); it('uses right key to navigate to the next tab when `orientation` is set to "horizontal".', () => { - tabListInputs.activeIndex.set(1); + tabListInputs.activeItem.set(tabPatterns[1]); expect(tabPatterns[1].active()).toBeTrue(); tabListPattern.onKeydown(right()); expect(tabPatterns[2].active()).toBeTrue(); @@ -293,7 +294,7 @@ describe('Tabs Pattern', () => { it('uses up key to navigate to the previous tab when `orientation` is set to "vertical".', () => { tabListInputs.orientation.set('vertical'); - tabListInputs.activeIndex.set(1); + tabListInputs.activeItem.set(tabPatterns[1]); expect(tabPatterns[1].active()).toBeTrue(); tabListPattern.onKeydown(up()); expect(tabPatterns[0].active()).toBeTrue(); @@ -301,21 +302,21 @@ describe('Tabs Pattern', () => { it('uses down key to navigate to the next tab when `orientation` is set to "vertical".', () => { tabListInputs.orientation.set('vertical'); - tabListInputs.activeIndex.set(1); + tabListInputs.activeItem.set(tabPatterns[1]); expect(tabPatterns[1].active()).toBeTrue(); tabListPattern.onKeydown(down()); expect(tabPatterns[2].active()).toBeTrue(); }); it('uses home key to navigate to the first tab.', () => { - tabListInputs.activeIndex.set(1); + tabListInputs.activeItem.set(tabPatterns[1]); expect(tabPatterns[1].active()).toBeTrue(); tabListPattern.onKeydown(home()); expect(tabPatterns[0].active()).toBeTrue(); }); it('uses end key to navigate to the last tab.', () => { - tabListInputs.activeIndex.set(1); + tabListInputs.activeItem.set(tabPatterns[1]); expect(tabPatterns[1].active()).toBeTrue(); tabListPattern.onKeydown(end()); expect(tabPatterns[2].active()).toBeTrue(); @@ -351,7 +352,7 @@ describe('Tabs Pattern', () => { it('changes the navigation direction with `rtl` mode.', () => { tabListInputs.textDirection.set('rtl'); - tabListInputs.activeIndex.set(1); + tabListInputs.activeItem.set(tabPatterns[1]); tabListPattern.onKeydown(left()); expect(tabPatterns[2].active()).toBeTrue(); }); diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index 53c83505b369..b605e8c137b7 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -20,7 +20,7 @@ import {List, ListInputs, ListItem} from '../behaviors/list/list'; /** The required inputs to tabs. */ export interface TabInputs - extends Omit, 'searchTerm'>, + extends Omit, 'searchTerm' | 'index'>, Omit { /** The parent tablist that controls the tab. */ tablist: SignalLike; @@ -37,6 +37,9 @@ export class TabPattern { /** A global unique identifier for the tab. */ readonly id: SignalLike; + /** The index of the tab. */ + readonly index = computed(() => this.inputs.tablist().inputs.items().indexOf(this)); + /** A local unique identifier for the tab. */ readonly value: SignalLike; @@ -59,7 +62,7 @@ export class TabPattern { readonly expanded = computed(() => this.expansion.isExpanded()); /** Whether the tab is active. */ - readonly active = computed(() => this.inputs.tablist().listBehavior.activeItem() === this); + readonly active = computed(() => this.inputs.tablist().inputs.activeItem() === this); /** Whether the tab is selected. */ readonly selected = computed(() => !!this.inputs.tablist().inputs.value().includes(this.value())); @@ -212,22 +215,22 @@ export class TabListPattern { * This method should be called once the tablist and its tabs are properly initialized. */ setDefaultState() { - let firstItemIndex: number | undefined; + let firstItem: TabPattern | undefined; - for (const [index, item] of this.inputs.items().entries()) { + for (const item of this.inputs.items()) { if (!this.listBehavior.isFocusable(item)) continue; - if (firstItemIndex === undefined) { - firstItemIndex = index; + if (firstItem === undefined) { + firstItem = item; } if (item.selected()) { - this.inputs.activeIndex.set(index); + this.inputs.activeItem.set(item); return; } } - if (firstItemIndex !== undefined) { - this.inputs.activeIndex.set(firstItemIndex); + if (firstItem !== undefined) { + this.inputs.activeItem.set(firstItem); } } diff --git a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts index 09ab0407f261..bc7c66d706e8 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts @@ -105,6 +105,7 @@ describe('Tree Pattern', () => { // Build tree items recursively. buildItems(treeData, tree as TreePattern); + tree.activeItem.set(allItems()[0]); return {tree, allItems, itemPatternInputsMap}; } @@ -135,7 +136,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(false), @@ -187,7 +188,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(false), @@ -232,7 +233,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(false), @@ -253,11 +254,11 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); - treeInputs.activeIndex.set(0); + treeInputs.activeItem.set(item0); expect(item0.active()).toBe(true); expect(item1.active()).toBe(false); - treeInputs.activeIndex.set(1); + treeInputs.activeItem.set(item1); expect(item0.active()).toBe(false); expect(item1.active()).toBe(true); }); @@ -275,9 +276,9 @@ describe('Tree Pattern', () => { const item1 = getItemByValue(allItems(), 'Item 1'); tree.listBehavior.goto(item0); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); tree.onKeydown(down()); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); }); it('should navigate prev on ArrowUp (vertical)', () => { @@ -287,9 +288,9 @@ describe('Tree Pattern', () => { const item1 = getItemByValue(allItems(), 'Item 1'); tree.listBehavior.goto(item1); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); tree.onKeydown(up()); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); }); it('should navigate next on ArrowRight (horizontal)', () => { @@ -299,9 +300,9 @@ describe('Tree Pattern', () => { const item1 = getItemByValue(allItems(), 'Item 1'); tree.listBehavior.goto(item0); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); tree.onKeydown(right()); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); }); it('should navigate prev on ArrowLeft (horizontal)', () => { @@ -311,22 +312,22 @@ describe('Tree Pattern', () => { const item1 = getItemByValue(allItems(), 'Item 1'); tree.listBehavior.goto(item1); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); tree.onKeydown(left()); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); }); it('should navigate next on ArrowLeft (horizontal & rtl)', () => { treeInputs.orientation.set('horizontal'); treeInputs.textDirection.set('rtl'); - treeInputs.activeIndex.set(0); const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); + treeInputs.activeItem.set(item0); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); tree.onKeydown(left()); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); }); it('should navigate prev on ArrowRight (horizontal & rtl)', () => { @@ -337,9 +338,9 @@ describe('Tree Pattern', () => { const item1 = getItemByValue(allItems(), 'Item 1'); tree.listBehavior.goto(item1); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); tree.onKeydown(right()); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); }); it('should navigate to the first visible item on Home', () => { @@ -348,9 +349,9 @@ describe('Tree Pattern', () => { const item2 = getItemByValue(allItems(), 'Item 2'); tree.listBehavior.goto(item2); - expect(tree.listBehavior.activeItem()).toBe(item2); + expect(tree.activeItem()).toBe(item2); tree.onKeydown(home()); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); }); it('should navigate to the last visible item on End', () => { @@ -359,9 +360,9 @@ describe('Tree Pattern', () => { const item2 = getItemByValue(allItems(), 'Item 2'); tree.listBehavior.goto(item0); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); tree.onKeydown(end()); - expect(tree.listBehavior.activeItem()).toBe(item2); + expect(tree.activeItem()).toBe(item2); }); it('should skip disabled items when skipDisabled is true', () => { @@ -376,9 +377,9 @@ describe('Tree Pattern', () => { const itemC = getItemByValue(allItems(), 'Item C'); tree.listBehavior.goto(itemA); - expect(tree.listBehavior.activeItem()).toBe(itemA); + expect(tree.activeItem()).toBe(itemA); tree.onKeydown(down()); - expect(tree.listBehavior.activeItem()).toBe(itemC); + expect(tree.activeItem()).toBe(itemC); }); it('should not skip disabled items when skipDisabled is false', () => { @@ -393,9 +394,9 @@ describe('Tree Pattern', () => { const itemB = getItemByValue(allItems(), 'Item B'); tree.listBehavior.goto(itemA); - expect(tree.listBehavior.activeItem()).toBe(itemA); + expect(tree.activeItem()).toBe(itemA); tree.onKeydown(down()); - expect(tree.listBehavior.activeItem()).toBe(itemB); + expect(tree.activeItem()).toBe(itemB); }); it('should not navigate when the tree is disabled', () => { @@ -404,9 +405,9 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); tree.listBehavior.goto(item0); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); tree.onKeydown(down()); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); }); }); @@ -416,7 +417,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(false), @@ -452,11 +453,11 @@ describe('Tree Pattern', () => { const item1 = getItemByValue(allItems(), 'Item 1'); tree.onKeydown(down()); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); expect(tree.inputs.value()).toEqual(['Item 1']); tree.onKeydown(up()); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); expect(tree.inputs.value()).toEqual(['Item 0']); }); @@ -474,7 +475,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(false), @@ -538,7 +539,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(true), @@ -599,7 +600,7 @@ describe('Tree Pattern', () => { tree.onKeydown(shift()); tree.onKeydown(up({shift: true})); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); expect(tree.inputs.value()).toEqual([]); }); @@ -696,7 +697,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(true), @@ -726,7 +727,7 @@ describe('Tree Pattern', () => { tree.onKeydown(down({control: true})); expect(tree.inputs.value()).toEqual(['Item 0']); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); }); it('should toggle an item selection state on Ctrl + Space', () => { @@ -761,7 +762,7 @@ describe('Tree Pattern', () => { tree.onKeydown(shift()); tree.onKeydown(up({shift: true})); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); expect(tree.inputs.value()).toEqual([]); }); @@ -846,7 +847,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(false), @@ -867,7 +868,7 @@ describe('Tree Pattern', () => { const item1 = getItemByValue(allItems(), 'Item 1'); tree.onPointerdown(createClickEvent(item1.element())); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); expect(tree.inputs.value()).toEqual(['Item 1']); }); @@ -886,7 +887,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(false), @@ -907,11 +908,11 @@ describe('Tree Pattern', () => { const item1 = getItemByValue(allItems(), 'Item 1'); tree.onPointerdown(createClickEvent(item1.element())); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); expect(tree.inputs.value()).toEqual(['Item 1']); tree.onPointerdown(createClickEvent(item1.element())); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); expect(tree.inputs.value()).toEqual([]); }); @@ -930,7 +931,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(true), @@ -978,7 +979,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(true), @@ -1050,7 +1051,7 @@ describe('Tree Pattern', () => { tree.onPointerdown(createClickEvent(itemA.element())); expect(tree.inputs.value()).toEqual([]); - expect(tree.listBehavior.activeItem()).toBe(itemA); + expect(tree.activeItem()).toBe(itemA); }); }); }); @@ -1060,7 +1061,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(false), @@ -1117,7 +1118,7 @@ describe('Tree Pattern', () => { item0.expansion.open(); tree.onKeydown(right()); - expect(tree.listBehavior.activeItem()).toBe(item0_0); + expect(tree.activeItem()).toBe(item0_0); }); it('should do nothing on expandKey if expanded and has no children (vertical)', () => { @@ -1127,7 +1128,7 @@ describe('Tree Pattern', () => { tree.listBehavior.goto(item1); tree.onKeydown(right()); - expect(tree.listBehavior.activeItem()).toBe(item1); + expect(tree.activeItem()).toBe(item1); }); it('should collapse an item on collapseKey if expanded (vertical)', () => { @@ -1151,7 +1152,7 @@ describe('Tree Pattern', () => { tree.listBehavior.goto(item0_0); tree.onKeydown(left()); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); }); it('should do nothing on collapseKey if collapsed and is a root item (vertical)', () => { @@ -1161,7 +1162,7 @@ describe('Tree Pattern', () => { tree.listBehavior.goto(item0); tree.onKeydown(left()); - expect(tree.listBehavior.activeItem()).toBe(item0); + expect(tree.activeItem()).toBe(item0); expect(item0.expanded()).toBe(false); }); @@ -1222,7 +1223,7 @@ describe('Tree Pattern', () => { beforeEach(() => { treeInputs = { - activeIndex: signal(0), + activeItem: signal(undefined), disabled: signal(false), focusMode: signal('roving'), multi: signal(false), @@ -1243,10 +1244,10 @@ describe('Tree Pattern', () => { {value: 'A', disabled: false}, {value: 'B', disabled: false}, ]; - const {tree} = createTree(localTreeData, treeInputs); + const {tree, allItems} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeIndex()).toBe(0); + expect(treeInputs.activeItem()).toBe(allItems()[0]); }); it('should set activeIndex to the first visible focusable disabled item if skipDisabled is false and no selection', () => { @@ -1255,10 +1256,10 @@ describe('Tree Pattern', () => { {value: 'B', disabled: false}, ]; treeInputs.skipDisabled.set(false); - const {tree} = createTree(localTreeData, treeInputs); + const {tree, allItems} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeIndex()).toBe(0); + expect(treeInputs.activeItem()).toBe(allItems()[0]); }); it('should set activeIndex to the first selected visible focusable item', () => { @@ -1268,10 +1269,10 @@ describe('Tree Pattern', () => { {value: 'C', disabled: false}, ]; treeInputs.value.set(['B']); - const {tree} = createTree(localTreeData, treeInputs); + const {tree, allItems} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeIndex()).toBe(1); + expect(treeInputs.activeItem()).toBe(allItems()[1]); }); it('should prioritize the first selected item in visible order', () => { @@ -1281,10 +1282,10 @@ describe('Tree Pattern', () => { {value: 'C', disabled: false}, ]; treeInputs.value.set(['C', 'A']); - const {tree} = createTree(localTreeData, treeInputs); + const {tree, allItems} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeIndex()).toBe(0); + expect(treeInputs.activeItem()).toBe(allItems()[0]); }); it('should skip a selected disabled item if skipDisabled is true', () => { @@ -1295,10 +1296,10 @@ describe('Tree Pattern', () => { ]; treeInputs.value.set(['B']); treeInputs.skipDisabled.set(true); - const {tree} = createTree(localTreeData, treeInputs); + const {tree, allItems} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeIndex()).toBe(0); + expect(treeInputs.activeItem()).toBe(allItems()[0]); }); it('should select a selected disabled item if skipDisabled is false', () => { @@ -1309,10 +1310,10 @@ describe('Tree Pattern', () => { ]; treeInputs.value.set(['B']); treeInputs.skipDisabled.set(false); - const {tree} = createTree(localTreeData, treeInputs); + const {tree, allItems} = createTree(localTreeData, treeInputs); tree.setDefaultState(); - expect(treeInputs.activeIndex()).toBe(1); + expect(treeInputs.activeItem()).toBe(allItems()[1]); }); it('should set activeIndex to first visible focusable item if selected item is not visible', () => { @@ -1323,7 +1324,7 @@ describe('Tree Pattern', () => { expect(item0.expanded()).toBe(false); expect(getItemByValue(allItems(), 'Item 0-0').visible()).toBe(false); tree.setDefaultState(); - expect(treeInputs.activeIndex()).toBe(0); + expect(treeInputs.activeItem()).toBe(item0); }); }); }); diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts index 6234b835bdd0..41acfbd7d8ad 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -13,7 +13,7 @@ import {ExpansionItem, ExpansionControl, ListExpansion} from '../behaviors/expan import {KeyboardEventManager, PointerEventManager, Modifier} from '../behaviors/event-manager'; /** Represents the required inputs for a tree item. */ -export interface TreeItemInputs extends ListItem { +export interface TreeItemInputs extends Omit, 'index'> { /** The parent item. */ parent: SignalLike | TreePattern>; @@ -32,6 +32,9 @@ export interface TreeItemPattern extends TreeItemInputs {} * Represents an item in a Tree. */ export class TreeItemPattern implements ExpansionItem { + /** The position of this item among its siblings. */ + readonly index = computed(() => this.tree().visibleItems().indexOf(this)); + /** The unique identifier used by the expansion behavior. */ readonly expansionId: SignalLike; @@ -60,7 +63,7 @@ export class TreeItemPattern implements ExpansionItem { readonly posinset = computed(() => this.parent().children().indexOf(this) + 1); /** Whether the item is active. */ - readonly active = computed(() => this.tree().listBehavior.activeItem() === this); + readonly active = computed(() => this.tree().activeItem() === this); /** The tabindex of the item. */ readonly tabindex = computed(() => this.tree().listBehavior.getItemTabindex(this)); @@ -226,7 +229,7 @@ export class TreePattern { manager // TODO: Tracking the anchor by index can break if the // tree is expanded or collapsed causing the index to change. - .on(Modifier.Any, 'Shift', () => list.anchor(this.inputs.activeIndex())) + .on(Modifier.Any, 'Shift', () => list.anchor(this.listBehavior.activeIndex())) .on(Modifier.Shift, this.prevKey, () => list.prev({selectRange: true})) .on(Modifier.Shift, this.nextKey, () => list.next({selectRange: true})) .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => @@ -310,7 +313,7 @@ export class TreePattern { this.allItems = inputs.allItems; this.focusMode = inputs.focusMode; this.disabled = inputs.disabled; - this.activeIndex = inputs.activeIndex; + this.activeItem = inputs.activeItem; this.skipDisabled = inputs.skipDisabled; this.wrap = inputs.wrap; this.orientation = inputs.orientation; @@ -342,24 +345,24 @@ export class TreePattern { * Otherwise, sets focus to the first focusable tree item. */ setDefaultState() { - let firstItemIndex: number | undefined; + let firstItem: TreeItemPattern | undefined; - for (const [index, item] of this.allItems().entries()) { + for (const item of this.allItems()) { if (!item.visible()) continue; if (!this.listBehavior.isFocusable(item)) continue; - if (firstItemIndex === undefined) { - firstItemIndex = index; + if (firstItem === undefined) { + firstItem = item; } if (item.selected()) { - this.inputs.activeIndex.set(index); + this.activeItem.set(item); return; } } - if (firstItemIndex !== undefined) { - this.inputs.activeIndex.set(firstItemIndex); + if (firstItem !== undefined) { + this.activeItem.set(firstItem); } } @@ -388,7 +391,7 @@ export class TreePattern { /** Toggles to expand or collapse a tree item. */ toggleExpansion(item?: TreeItemPattern) { - item ??= this.listBehavior.activeItem(); + item ??= this.activeItem(); if (!item || !this.listBehavior.isFocusable(item)) return; if (!item.expandable()) return; @@ -401,7 +404,7 @@ export class TreePattern { /** Expands a tree item. */ expand(item?: TreeItemPattern) { - item ??= this.listBehavior.activeItem(); + item ??= this.activeItem(); if (!item || !this.listBehavior.isFocusable(item)) return; if (item.expandable() && !item.expanded()) { @@ -416,14 +419,14 @@ export class TreePattern { /** Expands all sibling tree items including itself. */ expandSiblings(item?: TreeItemPattern) { - item ??= this.listBehavior.activeItem(); - const siblings = item.parent()?.children(); + item ??= this.activeItem(); + const siblings = item?.parent()?.children(); siblings?.forEach(item => this.expand(item)); } /** Collapses a tree item. */ collapse(item?: TreeItemPattern) { - item ??= this.listBehavior.activeItem(); + item ??= this.activeItem(); if (!item || !this.listBehavior.isFocusable(item)) return; if (item.expandable() && item.expanded()) {