diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/BUILD.bazel b/src/cdk-experimental/ui-patterns/behaviors/expansion/BUILD.bazel index 1e72caacfb44..ce0364627c10 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/expansion/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/BUILD.bazel @@ -9,6 +9,7 @@ ts_project( ], deps = [ "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) @@ -22,6 +23,7 @@ ts_project( deps = [ ":expansion", "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/behaviors/list-focus:unit_test_sources", ], ) diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts index 3248fb42c169..22b7285c5c56 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts @@ -6,41 +6,270 @@ * found in the LICENSE file at https://angular.dev/license */ -import {signal, WritableSignal} from '@angular/core'; -import {ExpansionControl, ExpansionPanel} from './expansion'; +import {Signal, WritableSignal, signal} from '@angular/core'; +import {Expansion, ExpansionInputs, ExpansionItem} from './expansion'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; +import {getListFocus as getListFocusManager} from '../list-focus/list-focus.spec'; + +type TestItem = ListFocusItem & + ExpansionItem & { + id: WritableSignal; + disabled: WritableSignal; + element: WritableSignal; + expandable: WritableSignal; + expansionId: WritableSignal; + }; + +type TestInputs = Partial, 'items' | 'focusManager'>> & + Partial< + Pick, 'focusMode' | 'disabled' | 'activeIndex' | 'skipDisabled'> + > & { + numItems?: number; + initialExpandedIds?: string[]; + }; + +function createItems(length: number): WritableSignal { + return signal( + Array.from({length}).map((_, i) => { + const itemId = `item-${i}`; + return { + id: signal(itemId), + element: signal(document.createElement('div') as HTMLElement), + disabled: signal(false), + expandable: signal(true), + expansionId: signal(itemId), + }; + }), + ); +} + +function getExpansion(inputs: TestInputs = {}): { + expansion: Expansion; + items: TestItem[]; + focusManager: ListFocus; +} { + const numItems = inputs.numItems ?? 3; + const items = createItems(numItems); + + const listFocusManagerInputs: Partial> & {items: Signal} = { + items: items, + activeIndex: inputs.activeIndex ?? signal(0), + disabled: inputs.disabled ?? signal(false), + skipDisabled: inputs.skipDisabled ?? signal(true), + focusMode: inputs.focusMode ?? signal('roving'), + }; + + const focusManager = getListFocusManager(listFocusManagerInputs as any) as ListFocus; + + const expansion = new Expansion({ + items: items, + activeIndex: focusManager.inputs.activeIndex, + disabled: focusManager.inputs.disabled, + skipDisabled: focusManager.inputs.skipDisabled, + focusMode: focusManager.inputs.focusMode, + multiExpandable: inputs.multiExpandable ?? signal(false), + expandedIds: signal([]), + focusManager, + }); + + if (inputs.initialExpandedIds) { + expansion.expandedIds.set(inputs.initialExpandedIds); + } + + return {expansion, items: items(), focusManager}; +} describe('Expansion', () => { - let testExpansionControl: ExpansionControl; - let panelVisibility: WritableSignal; - let testExpansionPanel: ExpansionPanel; + describe('#open', () => { + it('should open only one item at a time when multiExpandable is false', () => { + const {expansion, items} = getExpansion({ + multiExpandable: signal(false), + }); + + expansion.open(items[0]); + expect(expansion.expandedIds()).toEqual(['item-0']); + + expansion.open(items[1]); + expect(expansion.expandedIds()).toEqual(['item-1']); + }); + + it('should open multiple items when multiExpandable is true', () => { + const {expansion, items} = getExpansion({ + multiExpandable: signal(true), + }); + + expansion.open(items[0]); + expect(expansion.expandedIds()).toEqual(['item-0']); + + expansion.open(items[1]); + expect(expansion.expandedIds()).toEqual(['item-0', 'item-1']); + }); + + it('should not open an item if it is not expandable (expandable is false)', () => { + const {expansion, items} = getExpansion(); + items[1].expandable.set(false); + expansion.open(items[1]); + expect(expansion.expandedIds()).toEqual([]); + }); + + it('should not open an item if it is not focusable (disabled and skipDisabled is true)', () => { + const {expansion, items} = getExpansion({skipDisabled: signal(true)}); + items[1].disabled.set(true); + expansion.open(items[1]); + expect(expansion.expandedIds()).toEqual([]); + }); + }); + + describe('#close', () => { + it('should close the specified item', () => { + const {expansion, items} = getExpansion({initialExpandedIds: ['item-0', 'item-1']}); + expansion.close(items[0]); + expect(expansion.expandedIds()).toEqual(['item-1']); + }); + + it('should not close an item if it is not expandable', () => { + const {expansion, items} = getExpansion({initialExpandedIds: ['item-0']}); + items[0].expandable.set(false); + expansion.close(items[0]); + expect(expansion.expandedIds()).toEqual(['item-0']); + }); + + it('should not close an item if it is not focusable (disabled and skipDisabled is true)', () => { + const {expansion, items} = getExpansion({ + initialExpandedIds: ['item-0'], + skipDisabled: signal(true), + }); + items[0].disabled.set(true); + expansion.close(items[0]); + expect(expansion.expandedIds()).toEqual(['item-0']); + }); + }); - beforeEach(() => { - let expansionControlRef = signal(undefined); - let expansionPanelRef = signal(undefined); - panelVisibility = signal(false); - testExpansionControl = new ExpansionControl({ - visible: panelVisibility, - expansionPanel: expansionPanelRef, + describe('#toggle', () => { + it('should open a closed item', () => { + const {expansion, items} = getExpansion(); + expansion.toggle(items[0]); + expect(expansion.expandedIds()).toEqual(['item-0']); }); - testExpansionPanel = new ExpansionPanel({ - id: () => 'test-panel', - expansionControl: expansionControlRef, + + it('should close an opened item', () => { + const {expansion, items} = getExpansion({ + initialExpandedIds: ['item-0'], + }); + expansion.toggle(items[0]); + expect(expansion.expandedIds()).toEqual([]); }); - expansionControlRef.set(testExpansionControl); - expansionPanelRef.set(testExpansionPanel); }); - it('sets a panel hidden to true by setting a control to invisible.', () => { - panelVisibility.set(false); - expect(testExpansionPanel.hidden()).toBeTrue(); + describe('#openAll', () => { + it('should open all focusable and expandable items when multiExpandable is true', () => { + const {expansion} = getExpansion({ + numItems: 3, + multiExpandable: signal(true), + }); + expansion.openAll(); + expect(expansion.expandedIds()).toEqual(['item-0', 'item-1', 'item-2']); + }); + + it('should not expand items that are not expandable', () => { + const {expansion, items} = getExpansion({ + numItems: 3, + multiExpandable: signal(true), + }); + items[1].expandable.set(false); + expansion.openAll(); + expect(expansion.expandedIds()).toEqual(['item-0', 'item-2']); + }); + + it('should not expand items that are not focusable (disabled and skipDisabled is true)', () => { + const {expansion, items} = getExpansion({ + numItems: 3, + multiExpandable: signal(true), + }); + items[1].disabled.set(true); + expansion.openAll(); + expect(expansion.expandedIds()).toEqual(['item-0', 'item-2']); + }); + + it('should do nothing when multiExpandable is false', () => { + const {expansion} = getExpansion({ + numItems: 3, + multiExpandable: signal(false), + }); + expansion.openAll(); + expect(expansion.expandedIds()).toEqual([]); + }); }); - it('sets a panel hidden to false by setting a control to visible.', () => { - panelVisibility.set(true); - expect(testExpansionPanel.hidden()).toBeFalse(); + describe('#closeAll', () => { + it('should close all expanded items', () => { + const {expansion, items} = getExpansion({ + multiExpandable: signal(true), + initialExpandedIds: ['item-0', 'item-2'], + }); + items[1].expandable.set(false); + expansion.closeAll(); + expect(expansion.expandedIds()).toEqual([]); + }); + + it('should not close items that are not expandable', () => { + const {expansion, items} = getExpansion({ + multiExpandable: signal(true), + initialExpandedIds: ['item-0', 'item-1', 'item-2'], + }); + items[1].expandable.set(false); + expansion.closeAll(); + expect(expansion.expandedIds()).toEqual(['item-1']); + }); + + it('should not close items that are not focusable (disabled and skipDisabled is true)', () => { + const {expansion, items} = getExpansion({ + skipDisabled: signal(true), + multiExpandable: signal(true), + initialExpandedIds: ['item-0', 'item-1', 'item-2'], + }); + items[1].disabled.set(true); + expansion.closeAll(); + expect(expansion.expandedIds()).toEqual(['item-1']); + }); }); - it('gets a controlled panel id from ExpansionControl.', () => { - expect(testExpansionControl.controls()).toBe('test-panel'); + describe('#isExpandable', () => { + it('should return true if an item is focusable and expandable is true', () => { + const {expansion, items} = getExpansion(); + items[0].expandable.set(true); + items[0].disabled.set(false); + expect(expansion.isExpandable(items[0])).toBeTrue(); + }); + + it('should return true if an item is disabled and skipDisabled is false', () => { + const {expansion, items} = getExpansion({skipDisabled: signal(false)}); + items[0].disabled.set(true); + expect(expansion.isExpandable(items[0])).toBeTrue(); + }); + + it('should return false if an item is disabled and skipDisabled is true', () => { + const {expansion, items} = getExpansion({skipDisabled: signal(true)}); + items[0].disabled.set(true); + expect(expansion.isExpandable(items[0])).toBeFalse(); + }); + + it('should return false if expandable is false', () => { + const {expansion, items} = getExpansion(); + items[0].expandable.set(false); + expect(expansion.isExpandable(items[0])).toBeFalse(); + }); + }); + + describe('#isExpanded', () => { + it('should return true if item ID is in expandedIds', () => { + const {expansion, items} = getExpansion({initialExpandedIds: ['item-0']}); + expect(expansion.isExpanded(items[0])).toBeTrue(); + }); + + it('should return false if item ID is not in expandedIds', () => { + const {expansion, items} = getExpansion(); + expect(expansion.isExpanded(items[0])).toBeFalse(); + }); }); }); diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts index aadc354acd72..0d1c60c8251a 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts @@ -5,64 +5,87 @@ * 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} from '@angular/core'; -import {SignalLike} from '../signal-like/signal-like'; +import {computed, signal} from '@angular/core'; +import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../list-focus/list-focus'; -/** Inputs for an Expansion control. */ -export interface ExpansionControlInputs { - /** Whether an Expansion is visible. */ - visible: SignalLike; +/** Represents an item that can be expanded or collapsed. */ +export interface ExpansionItem extends ListFocusItem { + /** Whether the item is expandable. */ + expandable: SignalLike; - /** The controlled Expansion panel. */ - expansionPanel: SignalLike; + /** Used to uniquely identify an expansion item. */ + expansionId: SignalLike; } -/** Inputs for an Expansion panel. */ -export interface ExpansionPanelInputs { - /** A unique identifier for the panel. */ - id: SignalLike; +/** Represents the required inputs for an expansion behavior. */ +export interface ExpansionInputs extends ListFocusInputs { + /** Whether multiple items can be expanded at once. */ + multiExpandable: SignalLike; - /** The Expansion control. */ - expansionControl: SignalLike; + /** An array of ids of the currently expanded items. */ + expandedIds: WritableSignalLike; } -/** - * An Expansion control. - * - * Use Expansion behavior if a pattern has a collapsible view that has two elements rely on the - * states from each other. For example - * - * ```html - * - * - * ... - * - * - * ``` - */ -export class ExpansionControl { - /** Whether an Expansion is visible. */ - visible: SignalLike; +/** Manages the expansion state of a list of items. */ +export class Expansion { + /** A signal holding an array of ids of the currently expanded items. */ + expandedIds: WritableSignalLike; - /** The controllerd Expansion panel Id. */ - controls = computed(() => this.inputs.expansionPanel()?.id()); + /** The currently active (focused) item in the list. */ + activeItem = computed(() => this.inputs.focusManager.activeItem()); - constructor(readonly inputs: ExpansionControlInputs) { - this.visible = inputs.visible; + constructor(readonly inputs: ExpansionInputs & {focusManager: ListFocus}) { + this.expandedIds = inputs.expandedIds ?? signal([]); } -} -/** A Expansion panel. */ -export class ExpansionPanel { - /** A unique identifier for the panel. */ - id: SignalLike; + /** Opens the specified item, or the currently active item if none is specified. */ + open(item: T = this.activeItem()) { + if (this.isExpandable(item)) { + this.inputs.multiExpandable() + ? this.expandedIds.update(ids => ids.concat(item.expansionId())) + : this.expandedIds.set([item.expansionId()]); + } + } - /** Whether the panel is hidden. */ - hidden = computed(() => !this.inputs.expansionControl()?.visible()); + /** Closes the specified item, or the currently active item if none is specified. */ + close(item: T = this.activeItem()) { + if (this.isExpandable(item)) { + this.expandedIds.update(ids => ids.filter(id => id !== item.expansionId())); + } + } + + /** + * Toggles the expansion state of the specified item, + * or the currently active item if none is specified. + */ + toggle(item: T = this.activeItem()) { + this.expandedIds().includes(item.expansionId()) ? this.close(item) : this.open(item); + } + + /** Opens all focusable items in the list. */ + openAll() { + if (this.inputs.multiExpandable()) { + for (const item of this.inputs.items()) { + this.open(item); + } + } + } + + /** Closes all focusable items in the list. */ + closeAll() { + for (const item of this.inputs.items()) { + this.close(item); + } + } + + /** Checks whether the specified item is expandable / collapsible. */ + isExpandable(item: T) { + return this.inputs.focusManager.isFocusable(item) && item.expandable(); + } - constructor(readonly inputs: ExpansionPanelInputs) { - this.id = inputs.id; + /** Checks whether the specified item is currently expanded. */ + isExpanded(item: T): boolean { + return this.expandedIds().includes(item.expansionId()); } } diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index e824aee9a831..90a1302b694d 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -20,11 +20,15 @@ import { ListSelectionInputs, ListSelectionItem, } from '../behaviors/list-selection/list-selection'; -import {ExpansionControl, ExpansionPanel} from '../behaviors/expansion/expansion'; +import {Expansion, ExpansionInputs, ExpansionItem} from '../behaviors/expansion/expansion'; import {SignalLike} from '../behaviors/signal-like/signal-like'; /** The required inputs to tabs. */ -export interface TabInputs extends ListNavigationItem, ListSelectionItem, ListFocusItem { +export interface TabInputs + extends ListNavigationItem, + ListSelectionItem, + ListFocusItem, + Omit { /** The parent tablist that controls the tab. */ tablist: SignalLike; @@ -46,8 +50,14 @@ export class TabPattern { /** The html element that should receive focus. */ element: SignalLike; - /** Controls the expansion state for the tab. */ - expansionControl: ExpansionControl; + /** Whether this tab has expandable content. */ + expandable = () => true; + + /** The unique identifier used by the expansion behavior. */ + expansionId: SignalLike; + + /** Whether the tab is expanded. */ + expanded = computed(() => this.inputs.tablist().expansionBehavior.isExpanded(this)); /** Whether the tab is active. */ active = computed(() => this.inputs.tablist().focusManager.activeItem() === this); @@ -57,21 +67,18 @@ export class TabPattern { () => !!this.inputs.tablist().selection.inputs.value().includes(this.value()), ); - /** A tabpanel Id controlled by the tab. */ - controls = computed(() => this.expansionControl.controls()); - /** The tabindex of the tab. */ tabindex = computed(() => this.inputs.tablist().focusManager.getItemTabindex(this)); + /** The id of the tabpanel associated with the tab. */ + controls = computed(() => this.inputs.tabpanel()?.id()); + constructor(readonly inputs: TabInputs) { this.id = inputs.id; this.value = inputs.value; this.disabled = inputs.disabled; this.element = inputs.element; - this.expansionControl = new ExpansionControl({ - visible: this.selected, - expansionPanel: computed(() => inputs.tabpanel()?.expansionPanel), - }); + this.expansionId = inputs.value; } } @@ -90,34 +97,25 @@ export class TabPanelPattern { /** A local unique identifier for the tabpanel. */ value: SignalLike; - /** Represents the expansion state for the tabpanel. */ - expansionPanel: ExpansionPanel; - /** Whether the tabpanel is hidden. */ - hidden = computed(() => this.expansionPanel.hidden()); + hidden = computed(() => this.inputs.tab()?.expanded() === false); - constructor(inputs: TabPanelInputs) { + constructor(readonly inputs: TabPanelInputs) { this.id = inputs.id; this.value = inputs.value; - this.expansionPanel = new ExpansionPanel({ - id: inputs.id, - expansionControl: computed(() => inputs.tab()?.expansionControl), - }); } } /** The selection operations that the tablist can perform. */ interface SelectOptions { select?: boolean; - toggle?: boolean; - toggleOne?: boolean; - selectOne?: boolean; } /** The required inputs for the tablist. */ export type TabListInputs = ListNavigationInputs & Omit, 'multi'> & - ListFocusInputs & { + ListFocusInputs & + Omit, 'multiExpandable' | 'expandedIds'> & { disabled: SignalLike; }; @@ -132,6 +130,9 @@ export class TabListPattern { /** Controls focus for the tablist. */ focusManager: ListFocus; + /** Controls expansion for the tablist. */ + expansionBehavior: Expansion; + /** Whether the tablist is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; @@ -165,20 +166,18 @@ export class TabListPattern { /** The keydown event manager for the tablist. */ keydown = computed(() => { - const manager = new KeyboardEventManager() - .on(this.prevKey, () => this.prev({selectOne: this.followFocus()})) - .on(this.nextKey, () => this.next({selectOne: this.followFocus()})) - .on('Home', () => this.first({selectOne: this.followFocus()})) - .on('End', () => this.last({selectOne: this.followFocus()})) - .on(' ', () => this.selection.selectOne()) - .on('Enter', () => this.selection.selectOne()); - - return manager; + 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})); }); /** The pointerdown event manager for the tablist. */ pointerdown = computed(() => { - return new PointerEventManager().on(e => this.goto(e, {selectOne: true})); + return new PointerEventManager().on(e => this.goto(e, {select: true})); }); constructor(readonly inputs: TabListInputs) { @@ -187,11 +186,19 @@ export class TabListPattern { this.focusManager = new ListFocus(inputs); this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager}); + this.selection = new ListSelection({ ...inputs, multi: () => false, focusManager: this.focusManager, }); + + this.expansionBehavior = new Expansion({ + ...inputs, + multiExpandable: () => false, + expandedIds: this.inputs.value, + focusManager: this.focusManager, + }); } /** Handles keydown events for the tablist. */ @@ -211,25 +218,25 @@ export class TabListPattern { /** Navigates to the first option in the tablist. */ first(opts?: SelectOptions) { this.navigation.first(); - this._updateSelection(opts); + this._select(opts); } /** Navigates to the last option in the tablist. */ last(opts?: SelectOptions) { this.navigation.last(); - this._updateSelection(opts); + this._select(opts); } /** Navigates to the next option in the tablist. */ next(opts?: SelectOptions) { this.navigation.next(); - this._updateSelection(opts); + this._select(opts); } /** Navigates to the previous option in the tablist. */ prev(opts?: SelectOptions) { this.navigation.prev(); - this._updateSelection(opts); + this._select(opts); } /** Navigates to the given item in the tablist. */ @@ -238,23 +245,15 @@ export class TabListPattern { if (item) { this.navigation.goto(item); - this._updateSelection(opts); + this._select(opts); } } /** Handles updating selection for the tablist. */ - private _updateSelection(opts?: SelectOptions) { + private _select(opts?: SelectOptions) { if (opts?.select) { - this.selection.select(); - } - if (opts?.toggle) { - this.selection.toggle(); - } - if (opts?.toggleOne) { - this.selection.toggleOne(); - } - if (opts?.selectOne) { this.selection.selectOne(); + this.expansionBehavior.open(); } } diff --git a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.css b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.css index bdd38c5e1c5d..3b9757d3464b 100644 --- a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.css +++ b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.css @@ -67,7 +67,8 @@ background: var(--mat-sys-surface-container); } -.example-tab[aria-selected='true'] { +.example-tab[aria-selected='true'], +.example-tablist:focus-within .example-tab.cdk-active[aria-selected='true'] { background-color: var(--mat-sys-secondary-container); }