diff --git a/src/cdk-experimental/ui-patterns/behaviors/list/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/list/BUILD.bazel new file mode 100644 index 000000000000..f2fe55a531c2 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list/BUILD.bazel @@ -0,0 +1,37 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "list", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/event-manager", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", + "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", + "//src/cdk-experimental/ui-patterns/behaviors/list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/list-typeahead", + "//src/cdk-experimental/ui-patterns/behaviors/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = glob(["**/*.spec.ts"]), + deps = [ + ":list", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/behaviors/list/list.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/list/list.spec.ts new file mode 100644 index 000000000000..aa7dface99df --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list/list.spec.ts @@ -0,0 +1,346 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {signal, WritableSignal} from '@angular/core'; +import {List, ListItem, ListInputs} from './list'; +import {fakeAsync, tick} from '@angular/core/testing'; + +type TestItem = ListItem & { + disabled: WritableSignal; + searchTerm: WritableSignal; + value: WritableSignal; +}; + +type TestInputs = ListInputs, V>; +type TestList = List, V>; + +describe('List Behavior', () => { + function getList(inputs: Partial> & Pick, 'items'>): TestList { + return new List({ + value: inputs.value ?? signal([]), + activeIndex: inputs.activeIndex ?? signal(0), + typeaheadDelay: inputs.typeaheadDelay ?? signal(0.5), + wrap: inputs.wrap ?? signal(true), + disabled: inputs.disabled ?? signal(false), + multi: inputs.multi ?? signal(false), + textDirection: inputs.textDirection ?? signal('ltr'), + orientation: inputs.orientation ?? signal('vertical'), + focusMode: inputs.focusMode ?? signal('roving'), + skipDisabled: inputs.skipDisabled ?? signal(true), + selectionMode: signal('explicit'), + ...inputs, + }); + } + + function getItems(values: V[]): TestItem[] { + return values.map((value, index) => ({ + value: signal(value), + id: signal(`item-${index}`), + element: signal(document.createElement('div')), + disabled: signal(false), + searchTerm: signal(String(value)), + })); + } + + function getListAndItems(values: V[], inputs: Partial> = {}) { + const items = signal[]>([]); + const list = getList({...inputs, items}); + items.set(getItems(values)); + return {list, items: items()}; + } + + function getDefaultPatterns(inputs: Partial> = {}) { + return getListAndItems( + [ + 'Apple', + 'Apricot', + 'Banana', + 'Blackberry', + 'Blueberry', + 'Cantaloupe', + 'Cherry', + 'Clementine', + 'Cranberry', + ], + inputs, + ); + } + + describe('with focusMode: "activedescendant"', () => { + it('should set the list tabindex to 0', () => { + const {list} = getDefaultPatterns({focusMode: signal('activedescendant')}); + expect(list.tabindex()).toBe(0); + }); + + it('should set the active descendant to the active item id', () => { + const {list} = getDefaultPatterns({focusMode: signal('activedescendant')}); + expect(list.activedescendant()).toBe('item-0'); + list.next(); + expect(list.activedescendant()).toBe('item-1'); + }); + + it('should set item tabindex to -1', () => { + const {list, items} = getDefaultPatterns({focusMode: signal('activedescendant')}); + expect(list.getItemTabindex(items[0])).toBe(-1); + }); + }); + + describe('with focusMode: "roving"', () => { + it('should set the list tabindex to -1', () => { + const {list} = getDefaultPatterns({focusMode: signal('roving')}); + expect(list.tabindex()).toBe(-1); + }); + + it('should not set the active descendant', () => { + const {list} = getDefaultPatterns({focusMode: signal('roving')}); + expect(list.activedescendant()).toBeUndefined(); + }); + + it('should set the active item tabindex to 0 and others to -1', () => { + const {list, items} = getDefaultPatterns({focusMode: signal('roving')}); + expect(list.getItemTabindex(items[0])).toBe(0); + expect(list.getItemTabindex(items[1])).toBe(-1); + list.next(); + expect(list.getItemTabindex(items[0])).toBe(-1); + expect(list.getItemTabindex(items[1])).toBe(0); + }); + }); + + describe('with disabled: true', () => { + let list: TestList; + + beforeEach(() => { + const patterns = getDefaultPatterns({disabled: signal(true)}); + list = patterns.list; + }); + + it('should report disabled state', () => { + expect(list.disabled()).toBe(true); + }); + + it('should not change active index on navigation', () => { + expect(list.inputs.activeIndex()).toBe(0); + list.next(); + expect(list.inputs.activeIndex()).toBe(0); + list.last(); + expect(list.inputs.activeIndex()).toBe(0); + }); + + it('should not select items', () => { + list.next({selectOne: true}); + expect(list.inputs.value()).toEqual([]); + }); + + it('should have a tabindex of 0', () => { + expect(list.tabindex()).toBe(0); + }); + }); + + describe('Navigation', () => { + it('should navigate to the next item with next()', () => { + const {list} = getDefaultPatterns(); + expect(list.inputs.activeIndex()).toBe(0); + list.next(); + expect(list.inputs.activeIndex()).toBe(1); + }); + + it('should navigate to the previous item with prev()', () => { + const {list} = getDefaultPatterns({activeIndex: signal(1)}); + expect(list.inputs.activeIndex()).toBe(1); + list.prev(); + expect(list.inputs.activeIndex()).toBe(0); + }); + + it('should navigate to the first item with first()', () => { + const {list} = getDefaultPatterns({activeIndex: signal(8)}); + expect(list.inputs.activeIndex()).toBe(8); + list.first(); + expect(list.inputs.activeIndex()).toBe(0); + }); + + it('should navigate to the last item with last()', () => { + const {list} = getDefaultPatterns(); + expect(list.inputs.activeIndex()).toBe(0); + list.last(); + expect(list.inputs.activeIndex()).toBe(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); + list.next(); + expect(list.inputs.activeIndex()).toBe(2); // Should skip to 'Banana' + list.prev(); + expect(list.inputs.activeIndex()).toBe(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); + list.next(); + expect(list.inputs.activeIndex()).toBe(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); + list.next(); + expect(list.inputs.activeIndex()).toBe(8); // Stays at the end + list.first(); + expect(list.inputs.activeIndex()).toBe(0); + list.prev(); + expect(list.inputs.activeIndex()).toBe(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); + list.next(); + expect(list.inputs.activeIndex()).toBe(1); + list.prev(); + expect(list.inputs.activeIndex()).toBe(0); + }); + }); + + describe('Selection', () => { + describe('single select', () => { + let list: TestList; + let items: TestItem[]; + + beforeEach(() => { + const patterns = getDefaultPatterns({ + value: signal([]), + multi: signal(false), + }); + list = patterns.list; + items = patterns.items; + }); + + it('should not select when navigating', () => { + list.next(); + expect(list.inputs.value()).toEqual([]); + }); + + it('should select an item when navigating with selectOne:true', () => { + list.next({selectOne: true}); + expect(list.inputs.value()).toEqual(['Apricot']); + }); + + it('should toggle an item when navigating with toggle:true', () => { + list.goto(items[1], {selectOne: true}); + expect(list.inputs.value()).toEqual(['Apricot']); + + list.goto(items[1], {toggle: true}); + expect(list.inputs.value()).toEqual([]); + }); + + it('should only allow one selected item', () => { + list.next({selectOne: true}); + expect(list.inputs.value()).toEqual(['Apricot']); + list.next({selectOne: true}); + expect(list.inputs.value()).toEqual(['Banana']); + }); + }); + + describe('multi select', () => { + let list: TestList; + let items: TestItem[]; + + beforeEach(() => { + const patterns = getDefaultPatterns({ + value: signal([]), + multi: signal(true), + }); + list = patterns.list; + items = patterns.items; + }); + + it('should not select when navigating', () => { + list.next(); + expect(list.inputs.value()).toEqual([]); + }); + + it('should select an item with toggle:true', () => { + list.next({toggle: true}); + expect(list.inputs.value()).toEqual(['Apricot']); + }); + + it('should allow multiple selected items', () => { + list.next({toggle: true}); + list.next({toggle: true}); + expect(list.inputs.value()).toEqual(['Apricot', 'Banana']); + }); + + it('should select a range of items with selectRange:true', () => { + list.anchor(0); + list.next({selectRange: true}); + expect(list.inputs.value()).toEqual(['Apple', 'Apricot']); + list.next({selectRange: true}); + expect(list.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana']); + list.prev({selectRange: true}); + expect(list.inputs.value()).toEqual(['Apple', 'Apricot']); + list.prev({selectRange: true}); + expect(list.inputs.value()).toEqual(['Apple']); + }); + + it('should not wrap when range selecting', () => { + list.anchor(0); + list.prev({selectRange: true}); + expect(list.inputs.activeIndex()).toBe(0); + expect(list.inputs.value()).toEqual([]); + }); + + it('should not select disabled items in a range', () => { + items[1].disabled.set(true); + list.anchor(0); + list.goto(items[3], {selectRange: true}); + expect(list.inputs.value()).toEqual(['Apple', 'Banana', 'Blackberry']); + }); + }); + }); + + describe('Typeahead', () => { + it('should navigate to an item via typeahead', fakeAsync(() => { + const {list} = getDefaultPatterns(); + expect(list.inputs.activeIndex()).toBe(0); + list.search('b'); + expect(list.inputs.activeIndex()).toBe(2); // Banana + list.search('l'); + expect(list.inputs.activeIndex()).toBe(3); // Blackberry + list.search('u'); + expect(list.inputs.activeIndex()).toBe(4); // Blueberry + + tick(500); // Default delay + + list.search('c'); + expect(list.inputs.activeIndex()).toBe(5); // Cantaloupe + })); + + it('should respect typeaheadDelay', fakeAsync(() => { + const {list} = getDefaultPatterns({typeaheadDelay: signal(0.1)}); + list.search('b'); + expect(list.inputs.activeIndex()).toBe(2); // Banana + tick(50); // Less than delay + list.search('l'); + expect(list.inputs.activeIndex()).toBe(3); // Blackberry + tick(101); // More than delay + list.search('c'); + expect(list.inputs.activeIndex()).toBe(5); // Cantaloupe + })); + + it('should select an item via typeahead', () => { + const {list} = getDefaultPatterns({multi: signal(false)}); + list.search('b', {selectOne: true}); + expect(list.inputs.value()).toEqual(['Banana']); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/behaviors/list/list.ts b/src/cdk-experimental/ui-patterns/behaviors/list/list.ts new file mode 100644 index 000000000000..66f341538d27 --- /dev/null +++ b/src/cdk-experimental/ui-patterns/behaviors/list/list.ts @@ -0,0 +1,229 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, signal} from '@angular/core'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; +import { + ListNavigation, + ListNavigationInputs, + ListNavigationItem, +} from '../list-navigation/list-navigation'; +import { + ListSelection, + ListSelectionInputs, + ListSelectionItem, +} from '../list-selection/list-selection'; +import { + ListTypeahead, + ListTypeaheadInputs, + ListTypeaheadItem, +} from '../list-typeahead/list-typeahead'; + +/** The selection operations that the list can perform. */ +interface SelectOptions { + toggle?: boolean; + select?: boolean; + selectOne?: boolean; + selectRange?: boolean; + anchor?: boolean; +} + +/** Represents an item in the list. */ +export type ListItem = ListTypeaheadItem & + ListNavigationItem & + ListSelectionItem & + ListFocusItem; + +/** The necessary inputs for the list behavior. */ +export type ListInputs, V> = ListFocusInputs & + ListNavigationInputs & + ListSelectionInputs & + ListTypeaheadInputs; + +/** Controls the state of a list. */ +export class List, V> { + /** Controls navigation for the list. */ + navigationBehavior: ListNavigation; + + /** Controls selection for the list. */ + selectionBehavior: ListSelection; + + /** Controls typeahead for the list. */ + typeaheadBehavior: ListTypeahead; + + /** Controls focus for the list. */ + focusBehavior: ListFocus; + + /** Whether the list is disabled. */ + disabled = computed(() => this.focusBehavior.isListDisabled()); + + /** The id of the current active item. */ + activedescendant = computed(() => this.focusBehavior.getActiveDescendant()); + + /** The tabindex of the list. */ + tabindex = computed(() => this.focusBehavior.getListTabindex()); + + /** The currently active item in the list. */ + activeItem = computed(() => this.focusBehavior.activeItem()); + + /** + * The uncommitted index for selecting a range of options. + * + * NOTE: This is subtly distinct from the "rangeStartIndex" in the ListSelection behavior. + * The anchorIndex does not necessarily represent the start of a range, but represents the most + * recent index where the user showed intent to begin a range selection. Usually, this is wherever + * the user most recently pressed the "Shift" key, but if the user presses shift + space to select + * from the anchor, the user is not intending to start a new range from this index. + * + * In other words, "rangeStartIndex" is only set when a user commits to starting a range selection + * while "anchorIndex" is set whenever a user indicates they may be starting a range selection. + */ + private _anchorIndex = signal(0); + + /** Whether the list should wrap. Used to disable wrapping while range selecting. */ + private _wrap = signal(true); + + constructor(readonly inputs: ListInputs) { + this.focusBehavior = new ListFocus(inputs); + this.selectionBehavior = new ListSelection({...inputs, focusManager: this.focusBehavior}); + this.typeaheadBehavior = new ListTypeahead({...inputs, focusManager: this.focusBehavior}); + this.navigationBehavior = new ListNavigation({ + ...inputs, + focusManager: this.focusBehavior, + wrap: computed(() => this._wrap() && this.inputs.wrap()), + }); + } + + /** Returns the tabindex for the given item. */ + getItemTabindex(item: T) { + return this.focusBehavior.getItemTabindex(item); + } + + /** Navigates to the first option in the list. */ + first(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationBehavior.first()); + } + + /** Navigates to the last option in the list. */ + last(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationBehavior.last()); + } + + /** Navigates to the next option in the list. */ + next(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationBehavior.next()); + } + + /** Navigates to the previous option in the list. */ + prev(opts?: SelectOptions) { + this._navigate(opts, () => this.navigationBehavior.prev()); + } + + /** Navigates to the given item in the list. */ + goto(item: T, opts?: SelectOptions) { + this._navigate(opts, () => this.navigationBehavior.goto(item)); + } + + /** Marks the given index as the potential start of a range selection. */ + anchor(index: number) { + this._anchorIndex.set(index); + } + + /** Handles typeahead search navigation for the list. */ + search(char: string, opts?: SelectOptions) { + this._navigate(opts, () => this.typeaheadBehavior.search(char)); + } + + /** Checks if the list is currently typing for typeahead search. */ + isTyping() { + return this.typeaheadBehavior.isTyping(); + } + + /** Selects the currently active item in the list. */ + select() { + this.selectionBehavior.select(); + } + + /** Sets the selection to only the current active item. */ + selectOne() { + this.selectionBehavior.selectOne(); + } + + /** Deselects the currently active item in the list. */ + deselect() { + this.selectionBehavior.deselect(); + } + + /** Deselects all items in the list. */ + deselectAll() { + this.selectionBehavior.deselectAll(); + } + + /** Toggles the currently active item in the list. */ + toggle() { + this.selectionBehavior.toggle(); + } + + /** Toggles the currently active item in the list, deselecting all other items. */ + toggleOne() { + this.selectionBehavior.toggleOne(); + } + + /** Toggles the selection of all items in the list. */ + toggleAll() { + this.selectionBehavior.toggleAll(); + } + + /** Checks if the given item is able to receive focus. */ + isFocusable(item: T) { + return this.focusBehavior.isFocusable(item); + } + + /** Handles updating selection for the list. */ + updateSelection(opts: SelectOptions = {anchor: true}) { + if (opts.toggle) { + this.selectionBehavior.toggle(); + } + if (opts.select) { + this.selectionBehavior.select(); + } + if (opts.selectOne) { + this.selectionBehavior.selectOne(); + } + if (opts.selectRange) { + this.selectionBehavior.selectRange(); + } + if (!opts.anchor) { + this.anchor(this.selectionBehavior.rangeStartIndex()); + } + } + + /** + * Safely performs a navigation operation. + * + * Handles conditionally disabling wrapping for when a navigation + * operation is occurring while the user is selecting a range of options. + * + * Handles boilerplate calling of focus & selection operations. Also ensures these + * additional operations are only called if the navigation operation moved focus to a new option. + */ + private _navigate(opts: SelectOptions = {}, operation: () => boolean) { + if (opts?.selectRange) { + this._wrap.set(false); + this.selectionBehavior.rangeStartIndex.set(this._anchorIndex()); + } + + const moved = operation(); + + if (moved) { + this.updateSelection(opts); + } + + this._wrap.set(true); + } +} diff --git a/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel b/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel index 1f29a905a79c..4ea87e57ecd3 100644 --- a/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/listbox/BUILD.bazel @@ -11,10 +11,7 @@ ts_project( deps = [ "//:node_modules/@angular/core", "//src/cdk-experimental/ui-patterns/behaviors/event-manager", - "//src/cdk-experimental/ui-patterns/behaviors/list-focus", - "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", - "//src/cdk-experimental/ui-patterns/behaviors/list-selection", - "//src/cdk-experimental/ui-patterns/behaviors/list-typeahead", + "//src/cdk-experimental/ui-patterns/behaviors/list", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts index fb80cf822d3e..c29944b12f0c 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.spec.ts @@ -474,7 +474,7 @@ describe('Listbox Pattern', () => { }); it('should not allow wrapping while Shift is held down', () => { - listbox.selection.deselectAll(); + listbox.listBehavior.deselectAll(); listbox.onKeydown(shift()); listbox.onKeydown(up({shift: true})); expect(listbox.inputs.value()).toEqual([]); diff --git a/src/cdk-experimental/ui-patterns/listbox/listbox.ts b/src/cdk-experimental/ui-patterns/listbox/listbox.ts index 2af7f4b5e1f3..10237dce5e5f 100644 --- a/src/cdk-experimental/ui-patterns/listbox/listbox.ts +++ b/src/cdk-experimental/ui-patterns/listbox/listbox.ts @@ -8,63 +8,39 @@ import {OptionPattern} from './option'; import {KeyboardEventManager, PointerEventManager, Modifier} from '../behaviors/event-manager'; -import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/list-selection'; -import {ListTypeahead, ListTypeaheadInputs} from '../behaviors/list-typeahead/list-typeahead'; -import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation'; -import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus'; import {computed, signal} from '@angular/core'; import {SignalLike} from '../behaviors/signal-like/signal-like'; - -/** The selection operations that the listbox can perform. */ -interface SelectOptions { - toggle?: boolean; - selectOne?: boolean; - selectRange?: boolean; - anchor?: boolean; -} +import {List, ListInputs} from '../behaviors/list/list'; /** Represents the required inputs for a listbox. */ -export type ListboxInputs = ListNavigationInputs> & - ListSelectionInputs, V> & - ListTypeaheadInputs> & - ListFocusInputs> & { - readonly: SignalLike; - }; +export type ListboxInputs = ListInputs, V> & { + readonly: SignalLike; +}; /** Controls the state of a listbox. */ export class ListboxPattern { - /** Controls navigation for the listbox. */ - navigation: ListNavigation>; - - /** Controls selection for the listbox. */ - selection: ListSelection, V>; - - /** Controls typeahead for the listbox. */ - typeahead: ListTypeahead>; - - /** Controls focus for the listbox. */ - focusManager: ListFocus>; + listBehavior: List, V>; /** Whether the list is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; /** Whether the listbox is disabled. */ - disabled = computed(() => this.focusManager.isListDisabled()); + disabled = computed(() => this.listBehavior.disabled()); /** Whether the listbox is readonly. */ readonly: SignalLike; /** The tabindex of the listbox. */ - tabindex = computed(() => this.focusManager.getListTabindex()); + tabindex = computed(() => this.listBehavior.tabindex()); /** The id of the current active item. */ - activedescendant = computed(() => this.focusManager.getActiveDescendant()); + activedescendant = computed(() => this.listBehavior.activedescendant()); /** Whether multiple items in the list can be selected at once. */ multi: SignalLike; /** The number of items in the listbox. */ - setsize = computed(() => this.navigation.inputs.items().length); + setsize = computed(() => this.inputs.items().length); /** Whether the listbox selection follows focus. */ followFocus = computed(() => this.inputs.selectionMode() === 'follow'); @@ -89,98 +65,84 @@ export class ListboxPattern { }); /** Represents the space key. Does nothing when the user is actively using typeahead. */ - dynamicSpaceKey = computed(() => (this.typeahead.isTyping() ? '' : ' ')); + dynamicSpaceKey = computed(() => (this.listBehavior.isTyping() ? '' : ' ')); /** The regexp used to decide if a key should trigger typeahead. */ typeaheadRegexp = /^.$/; // TODO: Ignore spaces? - /** - * The uncommitted index for selecting a range of options. - * - * NOTE: This is subtly distinct from the "rangeStartIndex" in the ListSelection behavior. - * The anchorIndex does not necessarily represent the start of a range, but represents the most - * recent index where the user showed intent to begin a range selection. Usually, this is wherever - * the user most recently pressed the "Shift" key, but if the user presses shift + space to select - * from the anchor, the user is not intending to start a new range from this index. - * - * In other words, "rangeStartIndex" is only set when a user commits to starting a range selection - * while "anchorIndex" is set whenever a user indicates they may be starting a range selection. - */ - anchorIndex = signal(0); - /** The keydown event manager for the listbox. */ keydown = computed(() => { const manager = new KeyboardEventManager(); if (this.readonly()) { return manager - .on(this.prevKey, () => this.prev()) - .on(this.nextKey, () => this.next()) - .on('Home', () => this.first()) - .on('End', () => this.last()) - .on(this.typeaheadRegexp, e => this.search(e.key)); + .on(this.prevKey, () => this.listBehavior.prev()) + .on(this.nextKey, () => this.listBehavior.next()) + .on('Home', () => this.listBehavior.first()) + .on('End', () => this.listBehavior.last()) + .on(this.typeaheadRegexp, e => this.listBehavior.search(e.key)); } if (!this.followFocus()) { manager - .on(this.prevKey, () => this.prev()) - .on(this.nextKey, () => this.next()) - .on('Home', () => this.first()) - .on('End', () => this.last()) - .on(this.typeaheadRegexp, e => this.search(e.key)); + .on(this.prevKey, () => this.listBehavior.prev()) + .on(this.nextKey, () => this.listBehavior.next()) + .on('Home', () => this.listBehavior.first()) + .on('End', () => this.listBehavior.last()) + .on(this.typeaheadRegexp, e => this.listBehavior.search(e.key)); } if (this.followFocus()) { manager - .on(this.prevKey, () => this.prev({selectOne: true})) - .on(this.nextKey, () => this.next({selectOne: true})) - .on('Home', () => this.first({selectOne: true})) - .on('End', () => this.last({selectOne: true})) - .on(this.typeaheadRegexp, e => this.search(e.key, {selectOne: true})); + .on(this.prevKey, () => this.listBehavior.prev({selectOne: true})) + .on(this.nextKey, () => this.listBehavior.next({selectOne: true})) + .on('Home', () => this.listBehavior.first({selectOne: true})) + .on('End', () => this.listBehavior.last({selectOne: true})) + .on(this.typeaheadRegexp, e => this.listBehavior.search(e.key, {selectOne: true})); } if (this.inputs.multi()) { manager - .on(Modifier.Any, 'Shift', () => this.anchorIndex.set(this.inputs.activeIndex())) - .on(Modifier.Shift, this.prevKey, () => this.prev({selectRange: true})) - .on(Modifier.Shift, this.nextKey, () => this.next({selectRange: true})) + .on(Modifier.Any, 'Shift', () => this.listBehavior.anchor(this.inputs.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', () => - this.first({selectRange: true, anchor: false}), + this.listBehavior.first({selectRange: true, anchor: false}), ) .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'End', () => - this.last({selectRange: true, anchor: false}), + this.listBehavior.last({selectRange: true, anchor: false}), ) .on(Modifier.Shift, 'Enter', () => - this._updateSelection({selectRange: true, anchor: false}), + this.listBehavior.updateSelection({selectRange: true, anchor: false}), ) .on(Modifier.Shift, this.dynamicSpaceKey, () => - this._updateSelection({selectRange: true, anchor: false}), + this.listBehavior.updateSelection({selectRange: true, anchor: false}), ); } if (!this.followFocus() && this.inputs.multi()) { manager - .on(this.dynamicSpaceKey, () => this.selection.toggle()) - .on('Enter', () => this.selection.toggle()) - .on([Modifier.Ctrl, Modifier.Meta], 'A', () => this.selection.toggleAll()); + .on(this.dynamicSpaceKey, () => this.listBehavior.toggle()) + .on('Enter', () => this.listBehavior.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => this.listBehavior.toggleAll()); } if (!this.followFocus() && !this.inputs.multi()) { - manager.on(this.dynamicSpaceKey, () => this.selection.toggleOne()); - manager.on('Enter', () => this.selection.toggleOne()); + manager.on(this.dynamicSpaceKey, () => this.listBehavior.toggleOne()); + manager.on('Enter', () => this.listBehavior.toggleOne()); } if (this.inputs.multi() && this.followFocus()) { manager - .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => this.prev()) - .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => this.next()) - .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this.selection.toggle()) - .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => this.selection.toggle()) - .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => this.first()) - .on([Modifier.Ctrl, Modifier.Meta], 'End', () => this.last()) + .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => this.listBehavior.prev()) + .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => this.listBehavior.next()) + .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this.listBehavior.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => this.listBehavior.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => this.listBehavior.first()) + .on([Modifier.Ctrl, Modifier.Meta], 'End', () => this.listBehavior.last()) .on([Modifier.Ctrl, Modifier.Meta], 'A', () => { - this.selection.toggleAll(); - this.selection.select(); // Ensure the currect option remains selected. + this.listBehavior.toggleAll(); + this.listBehavior.select(); // Ensure the currect option remains selected. }); } @@ -192,29 +154,31 @@ export class ListboxPattern { const manager = new PointerEventManager(); if (this.readonly()) { - return manager.on(e => this.goto(e)); + return manager.on(e => this.listBehavior.goto(this._getItem(e)!)); } if (this.multi()) { - manager.on(Modifier.Shift, e => this.goto(e, {selectRange: true})); + manager.on(Modifier.Shift, e => + this.listBehavior.goto(this._getItem(e)!, {selectRange: true}), + ); } if (!this.multi() && this.followFocus()) { - return manager.on(e => this.goto(e, {selectOne: true})); + return manager.on(e => this.listBehavior.goto(this._getItem(e)!, {selectOne: true})); } if (!this.multi() && !this.followFocus()) { - return manager.on(e => this.goto(e, {toggle: true})); + return manager.on(e => this.listBehavior.goto(this._getItem(e)!, {toggle: true})); } if (this.multi() && this.followFocus()) { return manager - .on(e => this.goto(e, {selectOne: true})) - .on(Modifier.Ctrl, e => this.goto(e, {toggle: true})); + .on(e => this.listBehavior.goto(this._getItem(e)!, {selectOne: true})) + .on(Modifier.Ctrl, e => this.listBehavior.goto(this._getItem(e)!, {toggle: true})); } if (this.multi() && !this.followFocus()) { - return manager.on(e => this.goto(e, {toggle: true})); + return manager.on(e => this.listBehavior.goto(this._getItem(e)!, {toggle: true})); } return manager; @@ -225,14 +189,7 @@ export class ListboxPattern { 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.listBehavior = new List(inputs); } /** Returns a set of violations */ @@ -270,37 +227,6 @@ export class ListboxPattern { } } - /** Navigates to the first option in the listbox. */ - first(opts?: SelectOptions) { - this._navigate(opts, () => this.navigation.first()); - } - - /** Navigates to the last option in the listbox. */ - last(opts?: SelectOptions) { - this._navigate(opts, () => this.navigation.last()); - } - - /** Navigates to the next option in the listbox. */ - next(opts?: SelectOptions) { - this._navigate(opts, () => this.navigation.next()); - } - - /** Navigates to the previous option in the listbox. */ - prev(opts?: SelectOptions) { - this._navigate(opts, () => this.navigation.prev()); - } - - /** Navigates to the given item in the listbox. */ - goto(event: PointerEvent, opts?: SelectOptions) { - const item = this._getItem(event); - this._navigate(opts, () => this.navigation.goto(item)); - } - - /** Handles typeahead search navigation for the listbox. */ - search(char: string, opts?: SelectOptions) { - this._navigate(opts, () => this.typeahead.search(char)); - } - /** * Sets the listbox to it's default initial state. * @@ -315,7 +241,7 @@ export class ListboxPattern { let firstItem: OptionPattern | null = null; for (const item of this.inputs.items()) { - if (this.focusManager.isFocusable(item)) { + if (this.listBehavior.isFocusable(item)) { if (!firstItem) { firstItem = item; } @@ -331,46 +257,6 @@ export class ListboxPattern { } } - /** - * Safely performs a navigation operation. - * - * Handles conditionally disabling wrapping for when a navigation - * operation is occurring while the user is selecting a range of options. - * - * Handles boilerplate calling of focus & selection operations. Also ensures these - * additional operations are only called if the navigation operation moved focus to a new option. - */ - private _navigate(opts: SelectOptions = {}, operation: () => boolean) { - if (opts?.selectRange) { - this.wrap.set(false); - this.selection.rangeStartIndex.set(this.anchorIndex()); - } - - const moved = operation(); - - if (moved) { - this._updateSelection(opts); - } - - this.wrap.set(true); - } - - /** Handles updating selection for the listbox. */ - private _updateSelection(opts: SelectOptions = {anchor: true}) { - if (opts.toggle) { - this.selection.toggle(); - } - if (opts.selectOne) { - this.selection.selectOne(); - } - if (opts.selectRange) { - this.selection.selectRange(); - } - if (!opts.anchor) { - this.anchorIndex.set(this.selection.rangeStartIndex()); - } - } - private _getItem(e: PointerEvent) { if (!(e.target instanceof HTMLElement)) { return; diff --git a/src/cdk-experimental/ui-patterns/listbox/option.ts b/src/cdk-experimental/ui-patterns/listbox/option.ts index c27f4976f4e0..b8984ac8889c 100644 --- a/src/cdk-experimental/ui-patterns/listbox/option.ts +++ b/src/cdk-experimental/ui-patterns/listbox/option.ts @@ -7,28 +7,20 @@ */ import {computed} from '@angular/core'; -import {ListSelection, ListSelectionItem} from '../behaviors/list-selection/list-selection'; -import {ListTypeaheadItem} from '../behaviors/list-typeahead/list-typeahead'; -import {ListNavigation, ListNavigationItem} from '../behaviors/list-navigation/list-navigation'; -import {ListFocus, ListFocusItem} from '../behaviors/list-focus/list-focus'; import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {List, ListInputs, ListItem} from '../behaviors/list/list'; /** * Represents the properties exposed by a listbox that need to be accessed by an option. * This exists to avoid circular dependency errors between the listbox and option. */ interface ListboxPattern { - focusManager: ListFocus>; - selection: ListSelection, V>; - navigation: ListNavigation>; + inputs: ListInputs, V>; + listBehavior: List, V>; } /** Represents the required inputs for an option in a listbox. */ -export interface OptionInputs - extends ListNavigationItem, - ListSelectionItem, - ListTypeaheadItem, - ListFocusItem { +export interface OptionInputs extends ListItem { listbox: SignalLike | undefined>; } @@ -44,15 +36,15 @@ export class OptionPattern { index = computed( () => this.listbox() - ?.navigation.inputs.items() + ?.inputs.items() .findIndex(i => i.id() === this.id()) ?? -1, ); /** Whether the option is active. */ - active = computed(() => this.listbox()?.focusManager.activeItem() === this); + active = computed(() => this.listbox()?.listBehavior.activeItem() === this); /** Whether the option is selected. */ - selected = computed(() => this.listbox()?.selection.inputs.value().includes(this.value())); + selected = computed(() => this.listbox()?.inputs.value().includes(this.value())); /** Whether the option is disabled. */ disabled: SignalLike; @@ -64,7 +56,7 @@ export class OptionPattern { listbox: SignalLike | undefined>; /** The tabindex of the option. */ - tabindex = computed(() => this.listbox()?.focusManager.getItemTabindex(this)); + tabindex = computed(() => this.listbox()?.listBehavior.getItemTabindex(this)); /** The html element that should receive focus. */ element: SignalLike; diff --git a/src/cdk-experimental/ui-patterns/radio-group/BUILD.bazel b/src/cdk-experimental/ui-patterns/radio-group/BUILD.bazel index 34bd1b4a6168..21687e35c20a 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/radio-group/BUILD.bazel @@ -11,9 +11,7 @@ ts_project( deps = [ "//:node_modules/@angular/core", "//src/cdk-experimental/ui-patterns/behaviors/event-manager", - "//src/cdk-experimental/ui-patterns/behaviors/list-focus", - "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", - "//src/cdk-experimental/ui-patterns/behaviors/list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/list", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) 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 f9d7c2f9724f..528b94d51b90 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-button.ts @@ -7,26 +7,20 @@ */ import {computed} from '@angular/core'; -import {ListSelection, ListSelectionItem} from '../behaviors/list-selection/list-selection'; -import {ListNavigation, ListNavigationItem} from '../behaviors/list-navigation/list-navigation'; -import {ListFocus, ListFocusItem} from '../behaviors/list-focus/list-focus'; import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {List, ListItem} from '../behaviors/list/list'; /** * Represents the properties exposed by a radio group that need to be accessed by a radio button. * This exists to avoid circular dependency errors between the radio group and radio button. */ interface RadioGroupLike { - focusManager: ListFocus>; - selection: ListSelection, V>; - navigation: ListNavigation>; + /** The list behavior for the radio group. */ + listBehavior: List, V>; } /** Represents the required inputs for a radio button in a radio group. */ -export interface RadioButtonInputs - extends ListNavigationItem, - ListSelectionItem, - ListFocusItem { +export interface RadioButtonInputs extends Omit, 'searchTerm'> { /** A reference to the parent radio group. */ group: SignalLike | undefined>; } @@ -43,16 +37,16 @@ export class RadioButtonPattern { index = computed( () => this.group() - ?.navigation.inputs.items() + ?.listBehavior.inputs.items() .findIndex(i => i.id() === this.id()) ?? -1, ); /** Whether the radio button is currently the active one (focused). */ - active = computed(() => this.group()?.focusManager.activeItem() === this); + active = computed(() => this.group()?.listBehavior.activeItem() === this); /** Whether the radio button is selected. */ selected: SignalLike = computed( - () => !!this.group()?.selection.inputs.value().includes(this.value()), + () => !!this.group()?.listBehavior.inputs.value().includes(this.value()), ); /** Whether the radio button is disabled. */ @@ -62,11 +56,14 @@ export class RadioButtonPattern { group: SignalLike | undefined>; /** The tabindex of the radio button. */ - tabindex = computed(() => this.group()?.focusManager.getItemTabindex(this)); + tabindex = computed(() => this.group()?.listBehavior.getItemTabindex(this)); /** The HTML element associated with the radio button. */ element: SignalLike; + /** The search term for typeahead. */ + readonly searchTerm = () => ''; // Radio groups do not support typeahead. + constructor(readonly inputs: RadioButtonInputs) { this.id = inputs.id; this.value = inputs.value; 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 16590939c149..1029f35597aa 100644 --- a/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts +++ b/src/cdk-experimental/ui-patterns/radio-group/radio-group.ts @@ -8,56 +8,44 @@ import {computed} from '@angular/core'; import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; -import {ListFocus, ListFocusInputs} from '../behaviors/list-focus/list-focus'; -import {ListNavigation, ListNavigationInputs} from '../behaviors/list-navigation/list-navigation'; -import {ListSelection, ListSelectionInputs} from '../behaviors/list-selection/list-selection'; +import {List, ListInputs} from '../behaviors/list/list'; import {SignalLike} from '../behaviors/signal-like/signal-like'; import {RadioButtonPattern} from './radio-button'; -/** The selection operations that the radio group can perform. */ -interface SelectOptions { - selectOne?: boolean; -} - /** Represents the required inputs for a radio group. */ -export type RadioGroupInputs = Omit>, 'wrap'> & - // Radio groups are always single-select. - Omit, V>, 'multi' | 'selectionMode'> & - ListFocusInputs> & { - /** Whether the radio group is disabled. */ - disabled: SignalLike; - /** Whether the radio group is readonly. */ - readonly: SignalLike; - }; +export type RadioGroupInputs = Omit< + ListInputs, V>, + 'multi' | 'selectionMode' | 'wrap' | 'typeaheadDelay' +> & { + /** Whether the radio group is disabled. */ + disabled: SignalLike; + + /** Whether the radio group is readonly. */ + readonly: SignalLike; +}; /** Controls the state of a radio group. */ export class RadioGroupPattern { - /** Controls navigation for the radio group. */ - navigation: ListNavigation>; - - /** Controls selection for the radio group. */ - selection: ListSelection, V>; - - /** Controls focus for the radio group. */ - focusManager: ListFocus>; + /** The list behavior for the radio group. */ + readonly listBehavior: List, V>; /** Whether the radio group is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; /** Whether the radio group is disabled. */ - disabled = computed(() => this.inputs.disabled() || this.focusManager.isListDisabled()); + disabled = computed(() => this.inputs.disabled() || this.listBehavior.disabled()); /** The currently selected radio button. */ - selectedItem = computed(() => this.selection.selectedItems()[0]); + selectedItem = computed(() => this.listBehavior.selectionBehavior.selectedItems()[0]); /** Whether the radio group is readonly. */ readonly = computed(() => this.selectedItem()?.disabled() || this.inputs.readonly()); /** The tabindex of the radio group (if using activedescendant). */ - tabindex = computed(() => this.focusManager.getListTabindex()); + tabindex = computed(() => this.listBehavior.tabindex()); /** The id of the current active radio button (if using activedescendant). */ - activedescendant = computed(() => this.focusManager.getActiveDescendant()); + activedescendant = computed(() => this.listBehavior.activedescendant()); /** The key used to navigate to the previous radio button. */ prevKey = computed(() => { @@ -82,21 +70,21 @@ export class RadioGroupPattern { // Readonly mode allows navigation but not selection changes. if (this.readonly()) { return manager - .on(this.prevKey, () => this.prev()) - .on(this.nextKey, () => this.next()) - .on('Home', () => this.first()) - .on('End', () => this.last()); + .on(this.prevKey, () => this.listBehavior.prev()) + .on(this.nextKey, () => this.listBehavior.next()) + .on('Home', () => this.listBehavior.first()) + .on('End', () => this.listBehavior.last()); } // Default behavior: navigate and select on arrow keys, home, end. // Space/Enter also select the focused item. return manager - .on(this.prevKey, () => this.prev({selectOne: true})) - .on(this.nextKey, () => this.next({selectOne: true})) - .on('Home', () => this.first({selectOne: true})) - .on('End', () => this.last({selectOne: true})) - .on(' ', () => this.selection.selectOne()) - .on('Enter', () => this.selection.selectOne()); + .on(this.prevKey, () => this.listBehavior.prev({selectOne: true})) + .on(this.nextKey, () => this.listBehavior.next({selectOne: true})) + .on('Home', () => this.listBehavior.first({selectOne: true})) + .on('End', () => this.listBehavior.last({selectOne: true})) + .on(' ', () => this.listBehavior.selectOne()) + .on('Enter', () => this.listBehavior.selectOne()); }); /** The pointerdown event manager for the radio group. */ @@ -105,27 +93,22 @@ export class RadioGroupPattern { if (this.readonly()) { // Navigate focus only in readonly mode. - return manager.on(e => this.goto(e)); + return manager.on(e => this.listBehavior.goto(this._getItem(e)!)); } // Default behavior: navigate and select on click. - return manager.on(e => this.goto(e, {selectOne: true})); + return manager.on(e => this.listBehavior.goto(this._getItem(e)!, {selectOne: true})); }); constructor(readonly inputs: RadioGroupInputs) { this.orientation = inputs.orientation; - this.focusManager = new ListFocus(inputs); - this.navigation = new ListNavigation({ + this.listBehavior = new List({ ...inputs, wrap: () => false, - focusManager: this.focusManager, - }); - this.selection = new ListSelection({ - ...inputs, multi: () => false, selectionMode: () => 'follow', - focusManager: this.focusManager, + typeaheadDelay: () => 0, // Radio groups do not support typeahead. }); } @@ -143,32 +126,6 @@ export class RadioGroupPattern { } } - /** Navigates to the first enabled radio button in the group. */ - first(opts?: SelectOptions) { - this._navigate(opts, () => this.navigation.first()); - } - - /** Navigates to the last enabled radio button in the group. */ - last(opts?: SelectOptions) { - this._navigate(opts, () => this.navigation.last()); - } - - /** Navigates to the next enabled radio button in the group. */ - next(opts?: SelectOptions) { - this._navigate(opts, () => this.navigation.next()); - } - - /** Navigates to the previous enabled radio button in the group. */ - prev(opts?: SelectOptions) { - this._navigate(opts, () => this.navigation.prev()); - } - - /** Navigates to the radio button associated with the given pointer event. */ - goto(event: PointerEvent, opts?: SelectOptions) { - const item = this._getItem(event); - this._navigate(opts, () => this.navigation.goto(item)); - } - /** * Sets the radio group to its default initial state. * @@ -179,7 +136,7 @@ export class RadioGroupPattern { let firstItem: RadioButtonPattern | null = null; for (const item of this.inputs.items()) { - if (this.focusManager.isFocusable(item)) { + if (this.listBehavior.isFocusable(item)) { if (!firstItem) { firstItem = item; } @@ -208,14 +165,6 @@ export class RadioGroupPattern { return violations; } - /** Safely performs a navigation operation and updates selection if needed. */ - private _navigate(opts: SelectOptions = {}, operation: () => boolean) { - const moved = operation(); - if (moved && opts.selectOne) { - this.selection.selectOne(); - } - } - /** Finds the RadioButtonPattern associated with a pointer event target. */ private _getItem(e: PointerEvent): RadioButtonPattern | undefined { if (!(e.target instanceof HTMLElement)) { diff --git a/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel index 59c55914fc92..8520d843bf0e 100644 --- a/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel @@ -12,9 +12,7 @@ ts_project( "//src/cdk-experimental/ui-patterns/behaviors/event-manager", "//src/cdk-experimental/ui-patterns/behaviors/expansion", "//src/cdk-experimental/ui-patterns/behaviors/label", - "//src/cdk-experimental/ui-patterns/behaviors/list-focus", - "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", - "//src/cdk-experimental/ui-patterns/behaviors/list-selection", + "//src/cdk-experimental/ui-patterns/behaviors/list", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index 22553affb257..53c83505b369 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -8,17 +8,6 @@ import {computed} from '@angular/core'; import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; -import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; -import { - ListNavigation, - ListNavigationInputs, - ListNavigationItem, -} from '../behaviors/list-navigation/list-navigation'; -import { - ListSelection, - ListSelectionInputs, - ListSelectionItem, -} from '../behaviors/list-selection/list-selection'; import { ExpansionItem, ExpansionControl, @@ -27,12 +16,11 @@ import { } from '../behaviors/expansion/expansion'; import {SignalLike} from '../behaviors/signal-like/signal-like'; import {LabelControl, LabelControlOptionalInputs} from '../behaviors/label/label'; +import {List, ListInputs, ListItem} from '../behaviors/list/list'; /** The required inputs to tabs. */ export interface TabInputs - extends ListNavigationItem, - ListSelectionItem, - ListFocusItem, + extends Omit, 'searchTerm'>, Omit { /** The parent tablist that controls the tab. */ tablist: SignalLike; @@ -58,6 +46,9 @@ export class TabPattern { /** The html element that should receive focus. */ readonly element: SignalLike; + /** The text used by the typeahead search. */ + readonly searchTerm = () => ''; // Unused because tabs do not support typeahead. + /** Whether this tab has expandable content. */ readonly expandable = computed(() => this.expansion.expandable()); @@ -68,15 +59,13 @@ export class TabPattern { readonly expanded = computed(() => this.expansion.isExpanded()); /** Whether the tab is active. */ - readonly active = computed(() => this.inputs.tablist().focusManager.activeItem() === this); + readonly active = computed(() => this.inputs.tablist().listBehavior.activeItem() === this); /** Whether the tab is selected. */ - readonly selected = computed( - () => !!this.inputs.tablist().selection.inputs.value().includes(this.value()), - ); + readonly selected = computed(() => !!this.inputs.tablist().inputs.value().includes(this.value())); /** The tabindex of the tab. */ - readonly tabindex = computed(() => this.inputs.tablist().focusManager.getItemTabindex(this)); + readonly tabindex = computed(() => this.inputs.tablist().listBehavior.getItemTabindex(this)); /** The id of the tabpanel associated with the tab. */ readonly controls = computed(() => this.inputs.tabpanel()?.id()); @@ -136,27 +125,14 @@ export class TabPanelPattern { } } -/** The selection operations that the tablist can perform. */ -interface SelectOptions { - select?: boolean; -} - /** The required inputs for the tablist. */ -export type TabListInputs = ListNavigationInputs & - Omit, 'multi'> & - ListFocusInputs & +export type TabListInputs = Omit, 'multi' | 'typeaheadDelay'> & Omit; /** Controls the state of a tablist. */ export class TabListPattern { - /** Controls navigation for the tablist. */ - readonly navigation: ListNavigation; - - /** Controls selection for the tablist. */ - readonly selection: ListSelection; - - /** Controls focus for the tablist. */ - readonly focusManager: ListFocus; + /** The list behavior for the tablist. */ + readonly listBehavior: List; /** Controls expansion for the tablist. */ readonly expansionManager: ListExpansion; @@ -168,10 +144,10 @@ export class TabListPattern { readonly disabled: SignalLike; /** The tabindex of the tablist. */ - readonly tabindex = computed(() => this.focusManager.getListTabindex()); + readonly tabindex = computed(() => this.listBehavior.tabindex()); /** The id of the current active tab. */ - readonly activedescendant = computed(() => this.focusManager.getActiveDescendant()); + readonly activedescendant = computed(() => this.listBehavior.activedescendant()); /** Whether selection should follow focus. */ readonly followFocus = computed(() => this.inputs.selectionMode() === 'follow'); @@ -195,30 +171,29 @@ export class TabListPattern { /** The keydown event manager for the tablist. */ readonly keydown = computed(() => { return new KeyboardEventManager() - .on(this.prevKey, () => this.prev({select: this.followFocus()})) - .on(this.nextKey, () => this.next({select: this.followFocus()})) - .on('Home', () => this.first({select: this.followFocus()})) - .on('End', () => this.last({select: this.followFocus()})) - .on(' ', () => this._select({select: true})) - .on('Enter', () => this._select({select: true})); + .on(this.prevKey, () => this.listBehavior.prev({select: this.followFocus()})) + .on(this.nextKey, () => this.listBehavior.next({select: this.followFocus()})) + .on('Home', () => this.listBehavior.first({select: this.followFocus()})) + .on('End', () => this.listBehavior.last({select: this.followFocus()})) + .on(' ', () => this.listBehavior.select()) + .on('Enter', () => this.listBehavior.select()); }); /** The pointerdown event manager for the tablist. */ readonly pointerdown = computed(() => { - return new PointerEventManager().on(e => this.goto(e, {select: true})); + return new PointerEventManager().on(e => + this.listBehavior.goto(this._getItem(e)!, {select: true}), + ); }); constructor(readonly inputs: TabListInputs) { this.disabled = inputs.disabled; this.orientation = inputs.orientation; - this.focusManager = new ListFocus(inputs); - this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager}); - - this.selection = new ListSelection({ + this.listBehavior = new List({ ...inputs, multi: () => false, - focusManager: this.focusManager, + typeaheadDelay: () => 0, // Tabs do not support typeahead. }); this.expansionManager = new ListExpansion({ @@ -240,7 +215,7 @@ export class TabListPattern { let firstItemIndex: number | undefined; for (const [index, item] of this.inputs.items().entries()) { - if (!this.focusManager.isFocusable(item)) continue; + if (!this.listBehavior.isFocusable(item)) continue; if (firstItemIndex === undefined) { firstItemIndex = index; @@ -270,48 +245,7 @@ export class TabListPattern { } } - /** Navigates to the first option in the tablist. */ - first(opts?: SelectOptions) { - this.navigation.first(); - this._select(opts); - } - - /** Navigates to the last option in the tablist. */ - last(opts?: SelectOptions) { - this.navigation.last(); - this._select(opts); - } - - /** Navigates to the next option in the tablist. */ - next(opts?: SelectOptions) { - this.navigation.next(); - this._select(opts); - } - - /** Navigates to the previous option in the tablist. */ - prev(opts?: SelectOptions) { - this.navigation.prev(); - this._select(opts); - } - - /** Navigates to the given item in the tablist. */ - goto(event: PointerEvent, opts?: SelectOptions) { - const item = this._getItem(event); - - if (item) { - this.navigation.goto(item); - this._select(opts); - } - } - - /** Handles updating selection for the tablist. */ - private _select(opts?: SelectOptions) { - if (opts?.select) { - this.selection.selectOne(); - this.expansionManager.open(this.focusManager.activeItem()); - } - } - + /** Returns the tab item associated with the given pointer event. */ private _getItem(e: PointerEvent) { if (!(e.target instanceof HTMLElement)) { return; diff --git a/src/cdk-experimental/ui-patterns/tree/BUILD.bazel b/src/cdk-experimental/ui-patterns/tree/BUILD.bazel index 1a53f80c42eb..14147b62a329 100644 --- a/src/cdk-experimental/ui-patterns/tree/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/tree/BUILD.bazel @@ -11,10 +11,7 @@ ts_project( "//:node_modules/@angular/core", "//src/cdk-experimental/ui-patterns/behaviors/event-manager", "//src/cdk-experimental/ui-patterns/behaviors/expansion", - "//src/cdk-experimental/ui-patterns/behaviors/list-focus", - "//src/cdk-experimental/ui-patterns/behaviors/list-navigation", - "//src/cdk-experimental/ui-patterns/behaviors/list-selection", - "//src/cdk-experimental/ui-patterns/behaviors/list-typeahead", + "//src/cdk-experimental/ui-patterns/behaviors/list", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) diff --git a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts index 1f894bbf984a..09ab0407f261 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.spec.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.spec.ts @@ -265,7 +265,7 @@ describe('Tree Pattern', () => { it('should correctly compute tabindex state', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - expect(item0.tabindex()).toBe(tree.focusManager.getItemTabindex(item0)); + expect(item0.tabindex()).toBe(tree.listBehavior.getItemTabindex(item0)); }); it('should navigate next on ArrowDown (vertical)', () => { @@ -273,11 +273,11 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); - tree.navigationManager.goto(item0); + tree.listBehavior.goto(item0); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); tree.onKeydown(down()); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); }); it('should navigate prev on ArrowUp (vertical)', () => { @@ -285,11 +285,11 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); - tree.navigationManager.goto(item1); + tree.listBehavior.goto(item1); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); tree.onKeydown(up()); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); }); it('should navigate next on ArrowRight (horizontal)', () => { @@ -297,11 +297,11 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); - tree.navigationManager.goto(item0); + tree.listBehavior.goto(item0); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); tree.onKeydown(right()); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); }); it('should navigate prev on ArrowLeft (horizontal)', () => { @@ -309,11 +309,11 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); - tree.navigationManager.goto(item1); + tree.listBehavior.goto(item1); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); tree.onKeydown(left()); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); }); it('should navigate next on ArrowLeft (horizontal & rtl)', () => { @@ -324,9 +324,9 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); tree.onKeydown(left()); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); }); it('should navigate prev on ArrowRight (horizontal & rtl)', () => { @@ -335,33 +335,33 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); - tree.navigationManager.goto(item1); + tree.listBehavior.goto(item1); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); tree.onKeydown(right()); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); }); it('should navigate to the first visible item on Home', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item2 = getItemByValue(allItems(), 'Item 2'); - tree.navigationManager.goto(item2); + tree.listBehavior.goto(item2); - expect(tree.focusManager.activeItem()).toBe(item2); + expect(tree.listBehavior.activeItem()).toBe(item2); tree.onKeydown(home()); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); }); it('should navigate to the last visible item on End', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item2 = getItemByValue(allItems(), 'Item 2'); - tree.navigationManager.goto(item0); + tree.listBehavior.goto(item0); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); tree.onKeydown(end()); - expect(tree.focusManager.activeItem()).toBe(item2); + expect(tree.listBehavior.activeItem()).toBe(item2); }); it('should skip disabled items when skipDisabled is true', () => { @@ -374,11 +374,11 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(localTreeExample, treeInputs); const itemA = getItemByValue(allItems(), 'Item A'); const itemC = getItemByValue(allItems(), 'Item C'); - tree.navigationManager.goto(itemA); + tree.listBehavior.goto(itemA); - expect(tree.focusManager.activeItem()).toBe(itemA); + expect(tree.listBehavior.activeItem()).toBe(itemA); tree.onKeydown(down()); - expect(tree.focusManager.activeItem()).toBe(itemC); + expect(tree.listBehavior.activeItem()).toBe(itemC); }); it('should not skip disabled items when skipDisabled is false', () => { @@ -391,22 +391,22 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(localTreeExample, treeInputs); const itemA = getItemByValue(allItems(), 'Item A'); const itemB = getItemByValue(allItems(), 'Item B'); - tree.navigationManager.goto(itemA); + tree.listBehavior.goto(itemA); - expect(tree.focusManager.activeItem()).toBe(itemA); + expect(tree.listBehavior.activeItem()).toBe(itemA); tree.onKeydown(down()); - expect(tree.focusManager.activeItem()).toBe(itemB); + expect(tree.listBehavior.activeItem()).toBe(itemB); }); it('should not navigate when the tree is disabled', () => { treeInputs.disabled.set(true); const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - tree.navigationManager.goto(item0); + tree.listBehavior.goto(item0); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); tree.onKeydown(down()); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); }); }); @@ -452,11 +452,11 @@ describe('Tree Pattern', () => { const item1 = getItemByValue(allItems(), 'Item 1'); tree.onKeydown(down()); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); expect(tree.inputs.value()).toEqual(['Item 1']); tree.onKeydown(up()); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); expect(tree.inputs.value()).toEqual(['Item 0']); }); @@ -599,7 +599,7 @@ describe('Tree Pattern', () => { tree.onKeydown(shift()); tree.onKeydown(up({shift: true})); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); expect(tree.inputs.value()).toEqual([]); }); @@ -624,7 +624,7 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); item0.expansion.open(); - tree.navigationManager.goto(item1); + tree.listBehavior.goto(item1); tree.onKeydown(shift()); tree.onKeydown(home({control: true, shift: true})); @@ -636,7 +636,7 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); item0.expansion.open(); - tree.navigationManager.goto(item0_0); + tree.listBehavior.goto(item0_0); tree.onKeydown(shift()); tree.onKeydown(end({control: true, shift: true})); @@ -664,7 +664,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(localTreeData, treeInputs); const itemA = getItemByValue(allItems(), 'A'); - tree.navigationManager.goto(itemA); + tree.listBehavior.goto(itemA); tree.onKeydown(shift()); tree.onKeydown(down({shift: true})); tree.onKeydown(down({shift: true})); @@ -726,7 +726,7 @@ describe('Tree Pattern', () => { tree.onKeydown(down({control: true})); expect(tree.inputs.value()).toEqual(['Item 0']); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); }); it('should toggle an item selection state on Ctrl + Space', () => { @@ -757,11 +757,11 @@ describe('Tree Pattern', () => { it('should not allow wrapping while Shift is held down', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - tree.navigationManager.goto(item0); + tree.listBehavior.goto(item0); tree.onKeydown(shift()); tree.onKeydown(up({shift: true})); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); expect(tree.inputs.value()).toEqual([]); }); @@ -769,7 +769,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); item0.expansion.open(); - tree.navigationManager.goto(item0); + tree.listBehavior.goto(item0); tree.onKeydown(down({control: true})); tree.onKeydown(down({control: true})); @@ -783,7 +783,7 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); item0.expansion.open(); - tree.navigationManager.goto(item1); + tree.listBehavior.goto(item1); tree.onKeydown(shift()); tree.onKeydown(home({control: true, shift: true})); @@ -795,7 +795,7 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); item0.expansion.open(); - tree.navigationManager.goto(item0_0); + tree.listBehavior.goto(item0_0); tree.onKeydown(shift()); tree.onKeydown(end({control: true, shift: true})); @@ -819,7 +819,7 @@ describe('Tree Pattern', () => { treeInputs.skipDisabled.set(true); const {tree, allItems} = createTree(localTreeData, treeInputs); treeInputs.value.set(['A']); - tree.navigationManager.goto(getItemByValue(allItems(), 'A')); + tree.listBehavior.goto(getItemByValue(allItems(), 'A')); tree.onKeydown(down()); expect(tree.inputs.value()).toEqual(['C']); @@ -830,7 +830,7 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); item0.expansion.open(); - tree.navigationManager.goto(item0_0); + tree.listBehavior.goto(item0_0); tree.onKeydown(a({control: true})); expect(tree.inputs.value()).toEqual(['Item 0', 'Item 0-0', 'Item 0-1', 'Item 1', 'Item 2']); @@ -867,7 +867,7 @@ describe('Tree Pattern', () => { const item1 = getItemByValue(allItems(), 'Item 1'); tree.onPointerdown(createClickEvent(item1.element())); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); expect(tree.inputs.value()).toEqual(['Item 1']); }); @@ -907,11 +907,11 @@ describe('Tree Pattern', () => { const item1 = getItemByValue(allItems(), 'Item 1'); tree.onPointerdown(createClickEvent(item1.element())); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); expect(tree.inputs.value()).toEqual(['Item 1']); tree.onPointerdown(createClickEvent(item1.element())); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); expect(tree.inputs.value()).toEqual([]); }); @@ -1050,7 +1050,7 @@ describe('Tree Pattern', () => { tree.onPointerdown(createClickEvent(itemA.element())); expect(tree.inputs.value()).toEqual([]); - expect(tree.focusManager.activeItem()).toBe(itemA); + expect(tree.listBehavior.activeItem()).toBe(itemA); }); }); }); @@ -1101,7 +1101,7 @@ describe('Tree Pattern', () => { treeInputs.orientation.set('vertical'); const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - tree.navigationManager.goto(item0); + tree.listBehavior.goto(item0); expect(item0.expanded()).toBe(false); tree.onKeydown(right()); @@ -1113,28 +1113,28 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); - tree.navigationManager.goto(item0); + tree.listBehavior.goto(item0); item0.expansion.open(); tree.onKeydown(right()); - expect(tree.focusManager.activeItem()).toBe(item0_0); + expect(tree.listBehavior.activeItem()).toBe(item0_0); }); it('should do nothing on expandKey if expanded and has no children (vertical)', () => { treeInputs.orientation.set('vertical'); const {tree, allItems} = createTree(treeExample, treeInputs); const item1 = getItemByValue(allItems(), 'Item 1'); - tree.navigationManager.goto(item1); + tree.listBehavior.goto(item1); tree.onKeydown(right()); - expect(tree.focusManager.activeItem()).toBe(item1); + expect(tree.listBehavior.activeItem()).toBe(item1); }); it('should collapse an item on collapseKey if expanded (vertical)', () => { treeInputs.orientation.set('vertical'); const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - tree.navigationManager.goto(item0); + tree.listBehavior.goto(item0); item0.expansion.open(); expect(item0.expanded()).toBe(true); @@ -1148,20 +1148,20 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); item0.expansion.open(); - tree.navigationManager.goto(item0_0); + tree.listBehavior.goto(item0_0); tree.onKeydown(left()); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); }); it('should do nothing on collapseKey if collapsed and is a root item (vertical)', () => { treeInputs.orientation.set('vertical'); const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - tree.navigationManager.goto(item0); + tree.listBehavior.goto(item0); tree.onKeydown(left()); - expect(tree.focusManager.activeItem()).toBe(item0); + expect(tree.listBehavior.activeItem()).toBe(item0); expect(item0.expanded()).toBe(false); }); @@ -1170,7 +1170,7 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); const item2 = getItemByValue(allItems(), 'Item 2'); - tree.navigationManager.goto(item0); + tree.listBehavior.goto(item0); tree.onKeydown(asterisk({shift: true})); expect(item0.expanded()).toBe(true); diff --git a/src/cdk-experimental/ui-patterns/tree/tree.ts b/src/cdk-experimental/ui-patterns/tree/tree.ts index c66203cfc52d..6234b835bdd0 100644 --- a/src/cdk-experimental/ui-patterns/tree/tree.ts +++ b/src/cdk-experimental/ui-patterns/tree/tree.ts @@ -8,31 +8,12 @@ import {computed, signal} from '@angular/core'; import {SignalLike} from '../behaviors/signal-like/signal-like'; -import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; -import { - ListNavigation, - ListNavigationInputs, - ListNavigationItem, -} from '../behaviors/list-navigation/list-navigation'; -import { - ListSelection, - ListSelectionInputs, - ListSelectionItem, -} from '../behaviors/list-selection/list-selection'; -import { - ListTypeahead, - ListTypeaheadInputs, - ListTypeaheadItem, -} from '../behaviors/list-typeahead/list-typeahead'; +import {List, ListInputs, ListItem} from '../behaviors/list/list'; import {ExpansionItem, ExpansionControl, ListExpansion} from '../behaviors/expansion/expansion'; import {KeyboardEventManager, PointerEventManager, Modifier} from '../behaviors/event-manager'; /** Represents the required inputs for a tree item. */ -export interface TreeItemInputs - extends ListFocusItem, - ListNavigationItem, - ListSelectionItem, - ListTypeaheadItem { +export interface TreeItemInputs extends ListItem { /** The parent item. */ parent: SignalLike | TreePattern>; @@ -79,10 +60,10 @@ export class TreeItemPattern implements ExpansionItem { readonly posinset = computed(() => this.parent().children().indexOf(this) + 1); /** Whether the item is active. */ - readonly active = computed(() => this.tree().focusManager.activeItem() === this); + readonly active = computed(() => this.tree().listBehavior.activeItem() === this); /** The tabindex of the item. */ - readonly tabindex = computed(() => this.tree().focusManager.getItemTabindex(this)); + readonly tabindex = computed(() => this.tree().listBehavior.getItemTabindex(this)); /** Whether the item is selected. */ readonly selected = computed(() => { @@ -137,14 +118,7 @@ interface SelectOptions { } /** Represents the required inputs for a tree. */ -export interface TreeInputs - extends Omit< - ListFocusInputs> & - ListNavigationInputs> & - ListSelectionInputs, V> & - ListTypeaheadInputs>, - 'items' - > { +export interface TreeInputs extends Omit, V>, 'items'> { /** All items in the tree, in document order (DFS-like, a flattened list). */ allItems: SignalLike[]>; @@ -158,17 +132,8 @@ export interface TreeInputs export interface TreePattern extends TreeInputs {} /** Controls the state and interactions of a tree view. */ export class TreePattern { - /** Controls focus for the all visible tree items. */ - readonly focusManager: ListFocus>; - - /** Controls navigation for all visible tree items. */ - readonly navigationManager: ListNavigation>; - - /** Controls selection for all visible tree items. */ - readonly selectionManager: ListSelection, V>; - - /** Controls typeahead for all visible tree items. */ - readonly typeaheadManager: ListTypeahead>; + /** The list behavior for the tree. */ + readonly listBehavior: List, V>; /** Controls expansion for direct children of the tree root (top-level items). */ readonly expansionManager: ListExpansion; @@ -180,13 +145,10 @@ export class TreePattern { readonly expanded = () => true; /** The tabindex of the tree. */ - readonly tabindex = computed(() => this.focusManager.getListTabindex()); + readonly tabindex = computed(() => this.listBehavior.tabindex()); /** The id of the current active item. */ - readonly activedescendant = computed(() => this.focusManager.getActiveDescendant()); - - /** Whether the tree is performing a range selection. */ - readonly inSelection = signal(false); + readonly activedescendant = computed(() => this.listBehavior.activedescendant()); /** The direct children of the root (top-level tree items). */ readonly children = computed(() => @@ -232,87 +194,76 @@ export class TreePattern { }); /** Represents the space key. Does nothing when the user is actively using typeahead. */ - readonly dynamicSpaceKey = computed(() => (this.typeaheadManager.isTyping() ? '' : ' ')); + readonly dynamicSpaceKey = computed(() => (this.listBehavior.isTyping() ? '' : ' ')); /** Regular expression to match characters for typeahead. */ readonly typeaheadRegexp = /^.$/; - /** Uncommitted tree item for selecting a range of tree items. */ - readonly anchorItem = signal | undefined>(undefined); - - /** - * Uncommitted tree item index for selecting a range of tree items. - * - * The index is computed in case the tree item position is changed caused by tree expansions. - */ - readonly anchorIndex = computed(() => - this.anchorItem() ? this.visibleItems().indexOf(this.anchorItem()!) : -1, - ); - /** The keydown event manager for the tree. */ readonly keydown = computed(() => { const manager = new KeyboardEventManager(); + const list = this.listBehavior; if (!this.followFocus()) { manager - .on(this.prevKey, () => this.prev()) - .on(this.nextKey, () => this.next()) - .on('Home', () => this.first()) - .on('End', () => this.last()) - .on(this.typeaheadRegexp, e => this.search(e.key)); + .on(this.prevKey, () => list.prev()) + .on(this.nextKey, () => list.next()) + .on('Home', () => list.first()) + .on('End', () => list.last()) + .on(this.typeaheadRegexp, e => list.search(e.key)); } if (this.followFocus()) { manager - .on(this.prevKey, () => this.prev({selectOne: true})) - .on(this.nextKey, () => this.next({selectOne: true})) - .on('Home', () => this.first({selectOne: true})) - .on('End', () => this.last({selectOne: true})) - .on(this.typeaheadRegexp, e => this.search(e.key, {selectOne: true})); + .on(this.prevKey, () => list.prev({selectOne: true})) + .on(this.nextKey, () => list.next({selectOne: true})) + .on('Home', () => list.first({selectOne: true})) + .on('End', () => list.last({selectOne: true})) + .on(this.typeaheadRegexp, e => list.search(e.key, {selectOne: true})); } if (this.inputs.multi()) { manager - .on(Modifier.Any, 'Shift', () => this.anchorItem.set(this.focusManager.activeItem())) - .on(Modifier.Shift, this.prevKey, () => this.prev({selectRange: true})) - .on(Modifier.Shift, this.nextKey, () => this.next({selectRange: true})) + // 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.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', () => - this.first({selectRange: true, anchor: false}), + list.first({selectRange: true, anchor: false}), ) .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'End', () => - this.last({selectRange: true, anchor: false}), - ) - .on(Modifier.Shift, 'Enter', () => - this._updateSelection({selectRange: true, anchor: false}), + list.last({selectRange: true, anchor: false}), ) + .on(Modifier.Shift, 'Enter', () => list.updateSelection({selectRange: true, anchor: false})) .on(Modifier.Shift, this.dynamicSpaceKey, () => - this._updateSelection({selectRange: true, anchor: false}), + list.updateSelection({selectRange: true, anchor: false}), ); } if (!this.followFocus() && this.inputs.multi()) { manager - .on(this.dynamicSpaceKey, () => this.selectionManager.toggle()) - .on('Enter', () => this.selectionManager.toggle()) - .on([Modifier.Ctrl, Modifier.Meta], 'A', () => this.selectionManager.toggleAll()); + .on(this.dynamicSpaceKey, () => list.toggle()) + .on('Enter', () => list.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'A', () => list.toggleAll()); } if (!this.followFocus() && !this.inputs.multi()) { - manager.on(this.dynamicSpaceKey, () => this.selectionManager.toggleOne()); - manager.on('Enter', () => this.selectionManager.toggleOne()); + manager.on(this.dynamicSpaceKey, () => list.toggleOne()); + manager.on('Enter', () => list.toggleOne()); } if (this.inputs.multi() && this.followFocus()) { manager - .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => this.prev()) - .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => this.next()) - .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this.selectionManager.toggle()) - .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => this.selectionManager.toggle()) - .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => this.first()) - .on([Modifier.Ctrl, Modifier.Meta], 'End', () => this.last()) + .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => list.prev()) + .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => list.next()) + .on([Modifier.Ctrl, Modifier.Meta], ' ', () => list.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => list.toggle()) + .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => list.first()) + .on([Modifier.Ctrl, Modifier.Meta], 'End', () => list.last()) .on([Modifier.Ctrl, Modifier.Meta], 'A', () => { - this.selectionManager.toggleAll(); - this.selectionManager.select(); // Ensure the currect item remains selected. + list.toggleAll(); + list.select(); // Ensure the currect item remains selected. }); } @@ -365,29 +316,16 @@ export class TreePattern { this.orientation = inputs.orientation; this.textDirection = inputs.textDirection; this.multi = computed(() => (this.nav() ? false : this.inputs.multi())); - this.value = inputs.value; this.selectionMode = inputs.selectionMode; this.typeaheadDelay = inputs.typeaheadDelay; - this.focusManager = new ListFocus({ - ...inputs, - items: this.visibleItems, - }); - this.navigationManager = new ListNavigation({ - ...inputs, - wrap: computed(() => this.inputs.wrap() && !this.inSelection()), - items: this.visibleItems, - focusManager: this.focusManager, - }); - this.selectionManager = new ListSelection({ - ...inputs, - items: this.visibleItems, - focusManager: this.focusManager, - }); - this.typeaheadManager = new ListTypeahead({ + this.value = inputs.value; + + this.listBehavior = new List({ ...inputs, items: this.visibleItems, - focusManager: this.focusManager, + multi: this.multi, }); + this.expansionManager = new ListExpansion({ multiExpandable: () => true, // TODO(ok7sai): allow pre-expanded tree items. @@ -408,20 +346,20 @@ export class TreePattern { for (const [index, item] of this.allItems().entries()) { if (!item.visible()) continue; - if (!this.focusManager.isFocusable(item)) continue; + if (!this.listBehavior.isFocusable(item)) continue; if (firstItemIndex === undefined) { firstItemIndex = index; } if (item.selected()) { - this.activeIndex.set(index); + this.inputs.activeIndex.set(index); return; } } if (firstItemIndex !== undefined) { - this.activeIndex.set(firstItemIndex); + this.inputs.activeIndex.set(firstItemIndex); } } @@ -439,42 +377,19 @@ export class TreePattern { } } - /** Navigates to the first visible tree item in the tree. */ - first(opts?: SelectOptions) { - this._navigate(opts, () => this.navigationManager.first()); - } - - /** Navigates to the last visible tree item in the tree. */ - last(opts?: SelectOptions) { - this._navigate(opts, () => this.navigationManager.last()); - } - - /** Navigates to the next visible tree item in the tree. */ - next(opts?: SelectOptions) { - this._navigate(opts, () => this.navigationManager.next()); - } - - /** Navigates to the previous visible tree item in the tree. */ - prev(opts?: SelectOptions) { - this._navigate(opts, () => this.navigationManager.prev()); - } - /** Navigates to the given tree item in the tree. */ - goto(event: PointerEvent, opts?: SelectOptions) { - const item = this._getItem(event); - this._navigate(opts, () => this.navigationManager.goto(item)); - this.toggleExpansion(item); - } + goto(e: PointerEvent, opts?: SelectOptions) { + const item = this._getItem(e); + if (!item) return; - /** Handles typeahead search navigation for the tree. */ - search(char: string, opts?: SelectOptions) { - this._navigate(opts, () => this.typeaheadManager.search(char)); + this.listBehavior.goto(item, opts); + this.toggleExpansion(item); } /** Toggles to expand or collapse a tree item. */ toggleExpansion(item?: TreeItemPattern) { - item ??= this.focusManager.activeItem(); - if (!item || !this.focusManager.isFocusable(item)) return; + item ??= this.listBehavior.activeItem(); + if (!item || !this.listBehavior.isFocusable(item)) return; if (!item.expandable()) return; if (item.expanded()) { @@ -486,73 +401,41 @@ export class TreePattern { /** Expands a tree item. */ expand(item?: TreeItemPattern) { - item ??= this.focusManager.activeItem(); - if (!item || !this.focusManager.isFocusable(item)) return; + item ??= this.listBehavior.activeItem(); + if (!item || !this.listBehavior.isFocusable(item)) return; if (item.expandable() && !item.expanded()) { item.expansion.open(); } else if (item.expanded() && item.children().length > 0) { const firstChild = item.children()[0]; - if (this.focusManager.isFocusable(firstChild)) { - this.navigationManager.goto(firstChild); + if (this.listBehavior.isFocusable(firstChild)) { + this.listBehavior.goto(firstChild); } } } /** Expands all sibling tree items including itself. */ expandSiblings(item?: TreeItemPattern) { - item ??= this.focusManager.activeItem(); + item ??= this.listBehavior.activeItem(); const siblings = item.parent()?.children(); siblings?.forEach(item => this.expand(item)); } /** Collapses a tree item. */ collapse(item?: TreeItemPattern) { - item ??= this.focusManager.activeItem(); - if (!item || !this.focusManager.isFocusable(item)) return; + item ??= this.listBehavior.activeItem(); + if (!item || !this.listBehavior.isFocusable(item)) return; if (item.expandable() && item.expanded()) { item.expansion.close(); } else if (item.parent() && item.parent() !== this) { const parentItem = item.parent(); - if (parentItem instanceof TreeItemPattern && this.focusManager.isFocusable(parentItem)) { - this.navigationManager.goto(parentItem); + if (parentItem instanceof TreeItemPattern && this.listBehavior.isFocusable(parentItem)) { + this.listBehavior.goto(parentItem); } } } - /** Safely performs a navigation operation. */ - private _navigate(opts: SelectOptions = {}, operation: () => boolean) { - if (opts?.selectRange) { - this.inSelection.set(true); - this.selectionManager.rangeStartIndex.set(this.anchorIndex()); - } - - const moved = operation(); - - if (moved) { - this._updateSelection(opts); - } - - this.inSelection.set(false); - } - - /** Handles updating selection for the tree. */ - private _updateSelection(opts: SelectOptions = {anchor: true}) { - if (opts.toggle) { - this.selectionManager.toggle(); - } - if (opts.selectOne) { - this.selectionManager.selectOne(); - } - if (opts.selectRange) { - this.selectionManager.selectRange(); - } - if (!opts.anchor) { - this.anchorItem.set(this.visibleItems()[this.selectionManager.rangeStartIndex()]); - } - } - /** Retrieves the TreeItemPattern associated with a DOM event, if any. */ private _getItem(event: Event): TreeItemPattern | undefined { if (!(event.target instanceof HTMLElement)) {