diff --git a/src/cdk-experimental/ui-patterns/accordion/BUILD.bazel b/src/cdk-experimental/ui-patterns/accordion/BUILD.bazel new file mode 100644 index 000000000000..2bc65f76efbf --- /dev/null +++ b/src/cdk-experimental/ui-patterns/accordion/BUILD.bazel @@ -0,0 +1,38 @@ +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "accordion", + srcs = [ + "accordion.ts", + ], + deps = [ + "//: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/signal-like", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "accordion.spec.ts", + ], + deps = [ + ":accordion", + "//: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/accordion/accordion.spec.ts b/src/cdk-experimental/ui-patterns/accordion/accordion.spec.ts new file mode 100644 index 000000000000..bc076833e78d --- /dev/null +++ b/src/cdk-experimental/ui-patterns/accordion/accordion.spec.ts @@ -0,0 +1,369 @@ +/** + * @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} from '@angular/core'; +import { + AccordionGroupInputs, + AccordionGroupPattern, + AccordionPanelInputs, + AccordionPanelPattern, + AccordionTriggerInputs, + AccordionTriggerPattern, +} from './accordion'; +import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {ModifierKeys} from '@angular/cdk/testing'; + +// Converts the SignalLike type to WritableSignalLike type for controlling test inputs. +type WritableSignalOverrides = { + [K in keyof O as O[K] extends SignalLike ? K : never]: O[K] extends SignalLike + ? WritableSignalLike + : never; +}; + +type TestAccordionGroupInputs = AccordionGroupInputs & + WritableSignalOverrides; +type TestAccordionTriggerInputs = AccordionTriggerInputs & + WritableSignalOverrides; +type TestAccordionPanelInputs = AccordionPanelInputs & + WritableSignalOverrides; + +const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods); +const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); +const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods); +const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods); +const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods); +const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods); +const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods); +const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods); + +function createAccordionTriggerElement(): HTMLElement { + const element = document.createElement('button'); + element.setAttribute('role', 'button'); + return element; +} + +describe('Accordion Pattern', () => { + let groupInputs: TestAccordionGroupInputs; + let groupPattern: AccordionGroupPattern; + let triggerInputs: TestAccordionTriggerInputs[]; + let triggerPatterns: AccordionTriggerPattern[]; + let panelInputs: TestAccordionPanelInputs[]; + let panelPatterns: AccordionPanelPattern[]; + + beforeEach(() => { + // Initiate AccordionGroupPattern. + groupInputs = { + orientation: signal('vertical'), + textDirection: signal('ltr'), + activeIndex: signal(0), + disabled: signal(false), + multiExpandable: signal(true), + items: signal([]), + expandedIds: signal([]), + skipDisabled: signal(true), + wrap: signal(true), + }; + groupPattern = new AccordionGroupPattern(groupInputs); + + // Initiate a list of AccordionTriggerPattern. + triggerInputs = [ + { + accordionGroup: signal(groupPattern), + accordionPanel: signal(undefined), + id: signal('trigger-1-id'), + element: signal(createAccordionTriggerElement()), + disabled: signal(false), + value: signal('panel-1'), // Value should match the panel it controls + }, + { + accordionGroup: signal(groupPattern), + accordionPanel: signal(undefined), + id: signal('trigger-2-id'), + element: signal(createAccordionTriggerElement()), + disabled: signal(false), + value: signal('panel-2'), + }, + { + accordionGroup: signal(groupPattern), + accordionPanel: signal(undefined), + id: signal('trigger-3-id'), + element: signal(createAccordionTriggerElement()), + disabled: signal(false), + value: signal('panel-3'), + }, + ]; + triggerPatterns = [ + new AccordionTriggerPattern(triggerInputs[0]), + new AccordionTriggerPattern(triggerInputs[1]), + new AccordionTriggerPattern(triggerInputs[2]), + ]; + + // Initiate a list of AccordionPanelPattern. + panelInputs = [ + { + id: signal('panel-1-id'), + value: signal('panel-1'), + accordionTrigger: signal(undefined), + }, + { + id: signal('panel-2-id'), + value: signal('panel-2'), + accordionTrigger: signal(undefined), + }, + { + id: signal('panel-3-id'), + value: signal('panel-3'), + accordionTrigger: signal(undefined), + }, + ]; + panelPatterns = [ + new AccordionPanelPattern(panelInputs[0]), + new AccordionPanelPattern(panelInputs[1]), + new AccordionPanelPattern(panelInputs[2]), + ]; + + // Binding between triggers and panels. + triggerInputs[0].accordionPanel.set(panelPatterns[0]); + triggerInputs[1].accordionPanel.set(panelPatterns[1]); + triggerInputs[2].accordionPanel.set(panelPatterns[2]); + panelInputs[0].accordionTrigger.set(triggerPatterns[0]); + panelInputs[1].accordionTrigger.set(triggerPatterns[1]); + panelInputs[2].accordionTrigger.set(triggerPatterns[2]); + groupInputs.items.set(triggerPatterns); + }); + + it('expands a panel by setting `value`.', () => { + expect(triggerPatterns[0].expanded()).toBeFalse(); + groupInputs.expandedIds.set(['panel-1']); + expect(triggerPatterns[0].expanded()).toBeTrue(); + }); + + it('gets a controlled panel id from a trigger.', () => { + expect(panelPatterns[0].id()).toBe('panel-1-id'); + expect(triggerPatterns[0].controls()).toBe('panel-1-id'); + expect(panelPatterns[1].id()).toBe('panel-2-id'); + expect(triggerPatterns[1].controls()).toBe('panel-2-id'); + expect(panelPatterns[2].id()).toBe('panel-3-id'); + expect(triggerPatterns[2].controls()).toBe('panel-3-id'); + }); + + describe('Keyboard Navigation', () => { + it('does not handle keyboard event if an accordion group is disabled.', () => { + groupInputs.disabled.set(true); + triggerPatterns[0].onKeydown(space()); + expect(panelPatterns[0].hidden()).toBeTrue(); + }); + + it('does not handle keyboard event if an accordion trigger is disabled.', () => { + triggerInputs[0].disabled.set(true); + triggerPatterns[0].onKeydown(space()); + expect(panelPatterns[0].hidden()).toBeTrue(); + }); + + it('navigates to first accordion trigger with home key.', () => { + groupInputs.activeIndex.set(2); + expect(triggerPatterns[2].active()).toBeTrue(); + triggerPatterns[2].onKeydown(home()); + expect(triggerPatterns[2].active()).toBeFalse(); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + + it('navigates to last accordion trigger with end key.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + triggerPatterns[0].onKeydown(end()); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[2].active()).toBeTrue(); + }); + + describe('Vertical Orientation (orientation=vertical)', () => { + it('navigates to the next trigger with down key.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + expect(triggerPatterns[1].active()).toBeFalse(); + triggerPatterns[0].onKeydown(down()); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[1].active()).toBeTrue(); + }); + + it('navigates to the previous trigger with up key.', () => { + groupInputs.activeIndex.set(1); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[1].active()).toBeTrue(); + triggerPatterns[1].onKeydown(up()); + expect(triggerPatterns[1].active()).toBeFalse(); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + + describe('wrap=true', () => { + beforeEach(() => { + groupInputs.wrap.set(true); + }); + + it('navigates to the last trigger with up key from first trigger.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + expect(triggerPatterns[2].active()).toBeFalse(); + triggerPatterns[0].onKeydown(up()); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[2].active()).toBeTrue(); + }); + + it('navigates to the first trigger with down key from last trigger.', () => { + groupInputs.activeIndex.set(2); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[2].active()).toBeTrue(); + triggerPatterns[2].onKeydown(down()); + expect(triggerPatterns[0].active()).toBeTrue(); + expect(triggerPatterns[2].active()).toBeFalse(); + }); + }); + + describe('wrap=false', () => { + beforeEach(() => { + groupInputs.wrap.set(false); + }); + + it('stays on the first trigger with up key from first trigger.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + triggerPatterns[0].onKeydown(up()); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + + it('stays on the last trigger with down key from last trigger.', () => { + groupInputs.activeIndex.set(2); + expect(triggerPatterns[2].active()).toBeTrue(); + triggerPatterns[2].onKeydown(down()); + expect(triggerPatterns[2].active()).toBeTrue(); + }); + }); + }); + + describe('Horizontal Orientation (orientation=horizontal)', () => { + beforeEach(() => { + groupInputs.orientation.set('horizontal'); + }); + + it('navigates to the next trigger with right key.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + expect(triggerPatterns[1].active()).toBeFalse(); + triggerPatterns[0].onKeydown(right()); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[1].active()).toBeTrue(); + }); + + it('navigates to the previous trigger with left key.', () => { + groupInputs.activeIndex.set(1); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[1].active()).toBeTrue(); + triggerPatterns[1].onKeydown(left()); + expect(triggerPatterns[1].active()).toBeFalse(); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + + describe('wrap=true', () => { + beforeEach(() => { + groupInputs.wrap.set(true); + }); + + it('navigates to the last trigger with left key from first trigger.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + expect(triggerPatterns[2].active()).toBeFalse(); + triggerPatterns[0].onKeydown(left()); + expect(triggerPatterns[0].active()).toBeFalse(); + expect(triggerPatterns[2].active()).toBeTrue(); + }); + + it('navigates to the first trigger with right key from last trigger.', () => { + groupInputs.activeIndex.set(2); + expect(triggerPatterns[2].active()).toBeTrue(); + expect(triggerPatterns[0].active()).toBeFalse(); + triggerPatterns[2].onKeydown(right()); + expect(triggerPatterns[2].active()).toBeFalse(); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + }); + + describe('wrap=false', () => { + beforeEach(() => { + groupInputs.wrap.set(false); + }); + + it('stays on the first trigger with left key from first trigger.', () => { + groupInputs.activeIndex.set(0); + expect(triggerPatterns[0].active()).toBeTrue(); + triggerPatterns[0].onKeydown(left()); + expect(triggerPatterns[0].active()).toBeTrue(); + }); + + it('stays on the last trigger with right key from last trigger.', () => { + groupInputs.activeIndex.set(2); + expect(triggerPatterns[2].active()).toBeTrue(); + triggerPatterns[2].onKeydown(right()); + expect(triggerPatterns[2].active()).toBeTrue(); + }); + }); + }); + + describe('Single Expansion (multi=false)', () => { + beforeEach(() => { + groupInputs.multiExpandable.set(false); + }); + + it('expands a panel and collapses others with space key.', () => { + groupInputs.expandedIds.set(['panel-2']); + expect(panelPatterns[0].hidden()).toBeTrue(); + expect(panelPatterns[1].hidden()).toBeFalse(); + + triggerPatterns[0].onKeydown(space()); + expect(panelPatterns[0].hidden()).toBeFalse(); + expect(panelPatterns[1].hidden()).toBeTrue(); + }); + + it('expands a panel and collapses others with enter key.', () => { + groupInputs.expandedIds.set(['panel-2']); + expect(panelPatterns[0].hidden()).toBeTrue(); + expect(panelPatterns[1].hidden()).toBeFalse(); + + triggerPatterns[0].onKeydown(space()); + expect(panelPatterns[0].hidden()).toBeFalse(); + expect(panelPatterns[1].hidden()).toBeTrue(); + }); + }); + + describe('Multiple Expansion (multi=true)', () => { + beforeEach(() => { + groupInputs.multiExpandable.set(true); + }); + + it('expands a panel without affecting other panels.', () => { + groupInputs.expandedIds.set(['panel-2']); + expect(panelPatterns[0].hidden()).toBeTrue(); + expect(panelPatterns[1].hidden()).toBeFalse(); + + triggerPatterns[0].onKeydown(space()); + expect(panelPatterns[0].hidden()).toBeFalse(); + expect(panelPatterns[1].hidden()).toBeFalse(); + }); + + it('collapses a panel without affecting other panels.', () => { + groupInputs.expandedIds.set(['panel-1', 'panel-2']); + expect(panelPatterns[0].hidden()).toBeFalse(); + expect(panelPatterns[1].hidden()).toBeFalse(); + + triggerPatterns[0].onKeydown(enter()); + expect(panelPatterns[0].hidden()).toBeTrue(); + expect(panelPatterns[1].hidden()).toBeFalse(); + }); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/accordion/accordion.ts b/src/cdk-experimental/ui-patterns/accordion/accordion.ts new file mode 100644 index 000000000000..7258d5973a3a --- /dev/null +++ b/src/cdk-experimental/ui-patterns/accordion/accordion.ts @@ -0,0 +1,212 @@ +/** + * @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} from '@angular/core'; +import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; +import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; +import { + ExpansionItem, + ExpansionControl, + ListExpansion, + ListExpansionInputs, +} from '../behaviors/expansion/expansion'; +import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; +import { + ListNavigation, + ListNavigationInputs, + ListNavigationItem, +} from '../behaviors/list-navigation/list-navigation'; +import {SignalLike} from '../behaviors/signal-like/signal-like'; + +/** Inputs of the AccordionGroupPattern. */ +export type AccordionGroupInputs = Omit< + ListNavigationInputs & + ListFocusInputs & + ListExpansionInputs, + 'focusMode' +>; + +const focusMode = () => 'roving' as const; + +export interface AccordionGroupPattern extends AccordionGroupInputs {} +/** A pattern controls the nested Accordions. */ +export class AccordionGroupPattern { + /** Controls navigation for the group. */ + navigation: ListNavigation; + + /** Controls focus for the group. */ + focusManager: ListFocus; + + /** Controls expansion for the group. */ + expansionManager: ListExpansion; + + constructor(readonly inputs: AccordionGroupInputs) { + this.wrap = inputs.wrap; + this.orientation = inputs.orientation; + this.textDirection = inputs.textDirection; + this.activeIndex = inputs.activeIndex; + this.disabled = inputs.disabled; + this.multiExpandable = inputs.multiExpandable; + this.items = inputs.items; + this.expandedIds = inputs.expandedIds; + this.skipDisabled = inputs.skipDisabled; + this.focusManager = new ListFocus({ + ...inputs, + focusMode, + }); + this.navigation = new ListNavigation({ + ...inputs, + focusMode, + focusManager: this.focusManager, + }); + this.expansionManager = new ListExpansion({ + ...inputs, + focusMode, + focusManager: this.focusManager, + }); + } +} + +/** Inputs for the AccordionTriggerPattern. */ +export type AccordionTriggerInputs = ListNavigationItem & + ListFocusItem & + Omit & { + /** A local unique identifier for the trigger. */ + value: SignalLike; + + /** The parent accordion group that controls this trigger. */ + accordionGroup: SignalLike; + + /** The accordion panel controlled by this trigger. */ + accordionPanel: SignalLike; + }; + +export interface AccordionTriggerPattern extends AccordionTriggerInputs {} +/** A pattern controls the expansion state of an accordion. */ +export class AccordionTriggerPattern { + /** Whether this tab has expandable content. */ + expandable: SignalLike; + + /** The unique identifier used by the expansion behavior. */ + expansionId: SignalLike; + + /** Whether an accordion is expanded. */ + expanded: SignalLike; + + /** Controls the expansion state for the trigger. */ + expansionControl: ExpansionControl; + + /** Whether the trigger is active. */ + active = computed(() => this.inputs.accordionGroup().focusManager.activeItem() === this); + + /** Id of the accordion panel controlled by the trigger. */ + controls = computed(() => this.inputs.accordionPanel()?.id()); + + constructor(readonly inputs: AccordionTriggerInputs) { + this.element = inputs.element; + this.disabled = inputs.disabled; + this.value = inputs.value; + this.accordionGroup = inputs.accordionGroup; + this.accordionPanel = inputs.accordionPanel; + this.expansionControl = new ExpansionControl({ + ...inputs, + expansionId: inputs.value, + expandable: () => true, + expansionManager: inputs.accordionGroup().expansionManager, + }); + this.expandable = this.expansionControl.isExpandable; + this.expansionId = this.expansionControl.expansionId; + this.expanded = this.expansionControl.isExpanded; + } + + /** The key used to navigate to the previous accordion trigger. */ + prevKey = computed(() => { + if (this.inputs.accordionGroup().orientation() === 'vertical') { + return 'ArrowUp'; + } + return this.inputs.accordionGroup().textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + }); + + /** The key used to navigate to the next accordion trigger. */ + nextKey = computed(() => { + if (this.inputs.accordionGroup().orientation() === 'vertical') { + return 'ArrowDown'; + } + return this.inputs.accordionGroup().textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + }); + + /** The keydown event manager for the accordion trigger. */ + keydown = computed(() => { + return new KeyboardEventManager() + .on(this.prevKey, () => this.accordionGroup().navigation.prev()) + .on(this.nextKey, () => this.accordionGroup().navigation.next()) + .on('Home', () => this.accordionGroup().navigation.first()) + .on('End', () => this.accordionGroup().navigation.last()) + .on(' ', () => this.expansionControl.toggle()) + .on('Enter', () => this.expansionControl.toggle()); + }); + + /** The pointerdown event manager for the accordion trigger. */ + pointerdown = computed(() => { + return new PointerEventManager().on(e => { + const item = this._getItem(e); + + if (item) { + this.accordionGroup().navigation.goto(item); + this.expansionControl.toggle(); + } + }); + }); + + /** Handles keydown events on the trigger, delegating to the group if not disabled. */ + onKeydown(event: KeyboardEvent): void { + this.keydown().handle(event); + } + + /** Handles pointerdown events on the trigger, delegating to the group if not disabled. */ + onPointerdown(event: PointerEvent): void { + this.pointerdown().handle(event); + } + + private _getItem(e: PointerEvent) { + if (!(e.target instanceof HTMLElement)) { + return; + } + + const element = e.target.closest('[role="button"]'); + return this.accordionGroup() + .items() + .find(i => i.element() === element); + } +} + +/** Represents the required inputs for the AccordionPanelPattern. */ +export interface AccordionPanelInputs { + /** A global unique identifier for the panel. */ + id: SignalLike; + + /** A local unique identifier for the panel, matching its trigger's value. */ + value: SignalLike; + + /** The parent accordion trigger that controls this panel. */ + accordionTrigger: SignalLike; +} + +export interface AccordionPanelPattern extends AccordionPanelInputs {} +/** Represents an accordion panel. */ +export class AccordionPanelPattern { + /** Whether the accordion panel is hidden. True if the associated trigger is not expanded. */ + hidden: SignalLike; + + constructor(readonly inputs: AccordionPanelInputs) { + this.id = inputs.id; + this.value = inputs.value; + this.accordionTrigger = inputs.accordionTrigger; + this.hidden = computed(() => inputs.accordionTrigger()?.expanded() === false); + } +} diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts index 0d1c60c8251a..6c5824f590f8 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts @@ -18,8 +18,43 @@ export interface ExpansionItem extends ListFocusItem { expansionId: SignalLike; } +export interface ExpansionControl extends ExpansionItem {} +/** + * Controls a single item's expansion state and interactions, + * delegating actual state changes to an Expansion manager. + */ +export class ExpansionControl { + /** Whether this specific item is currently expanded. Derived from the Expansion manager. */ + readonly isExpanded = computed(() => this.inputs.expansionManager.isExpanded(this)); + + /** Whether this item can be expanded. */ + readonly isExpandable = computed(() => this.inputs.expansionManager.isExpandable(this)); + + constructor(readonly inputs: ExpansionItem & {expansionManager: ListExpansion}) { + this.expansionId = inputs.expansionId; + this.expandable = inputs.expandable; + this.element = inputs.element; + this.disabled = inputs.disabled; + } + + /** Requests the Expansopn manager to open this item. */ + open() { + this.inputs.expansionManager.open(this); + } + + /** Requests the Expansion manager to close this item. */ + close() { + this.inputs.expansionManager.close(this); + } + + /** Requests the Expansion manager to toggle this item. */ + toggle() { + this.inputs.expansionManager.toggle(this); + } +} + /** Represents the required inputs for an expansion behavior. */ -export interface ExpansionInputs extends ListFocusInputs { +export interface ListExpansionInputs extends ListFocusInputs { /** Whether multiple items can be expanded at once. */ multiExpandable: SignalLike; @@ -28,14 +63,14 @@ export interface ExpansionInputs extends ListFocusInput } /** Manages the expansion state of a list of items. */ -export class Expansion { +export class ListExpansion { /** A signal holding an array of ids of the currently expanded items. */ expandedIds: WritableSignalLike; /** The currently active (focused) item in the list. */ activeItem = computed(() => this.inputs.focusManager.activeItem()); - constructor(readonly inputs: ExpansionInputs & {focusManager: ListFocus}) { + constructor(readonly inputs: ListExpansionInputs & {focusManager: ListFocus}) { this.expandedIds = inputs.expandedIds ?? signal([]); } @@ -81,7 +116,9 @@ export class Expansion { /** Checks whether the specified item is expandable / collapsible. */ isExpandable(item: T) { - return this.inputs.focusManager.isFocusable(item) && item.expandable(); + return ( + !this.inputs.disabled() && this.inputs.focusManager.isFocusable(item) && item.expandable() + ); } /** Checks whether the specified item is currently expanded. */ diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index 90a1302b694d..9f9b08f2804d 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -20,7 +20,12 @@ import { ListSelectionInputs, ListSelectionItem, } from '../behaviors/list-selection/list-selection'; -import {Expansion, ExpansionInputs, ExpansionItem} from '../behaviors/expansion/expansion'; +import { + ExpansionItem, + ExpansionControl, + ListExpansionInputs, + ListExpansion, +} from '../behaviors/expansion/expansion'; import {SignalLike} from '../behaviors/signal-like/signal-like'; /** The required inputs to tabs. */ @@ -51,13 +56,13 @@ export class TabPattern { element: SignalLike; /** Whether this tab has expandable content. */ - expandable = () => true; + expandable: SignalLike; /** The unique identifier used by the expansion behavior. */ expansionId: SignalLike; /** Whether the tab is expanded. */ - expanded = computed(() => this.inputs.tablist().expansionBehavior.isExpanded(this)); + expanded: SignalLike; /** Whether the tab is active. */ active = computed(() => this.inputs.tablist().focusManager.activeItem() === this); @@ -78,7 +83,15 @@ export class TabPattern { this.value = inputs.value; this.disabled = inputs.disabled; this.element = inputs.element; - this.expansionId = inputs.value; + const expansionControl = new ExpansionControl({ + ...inputs, + expansionId: inputs.value, + expandable: () => true, + expansionManager: inputs.tablist().expansionManager, + }); + this.expansionId = expansionControl.expansionId; + this.expandable = expansionControl.isExpandable; + this.expanded = expansionControl.isExpanded; } } @@ -115,7 +128,7 @@ interface SelectOptions { export type TabListInputs = ListNavigationInputs & Omit, 'multi'> & ListFocusInputs & - Omit, 'multiExpandable' | 'expandedIds'> & { + Omit, 'multiExpandable' | 'expandedIds'> & { disabled: SignalLike; }; @@ -131,7 +144,7 @@ export class TabListPattern { focusManager: ListFocus; /** Controls expansion for the tablist. */ - expansionBehavior: Expansion; + expansionManager: ListExpansion; /** Whether the tablist is vertically or horizontally oriented. */ orientation: SignalLike<'vertical' | 'horizontal'>; @@ -193,7 +206,7 @@ export class TabListPattern { focusManager: this.focusManager, }); - this.expansionBehavior = new Expansion({ + this.expansionManager = new ListExpansion({ ...inputs, multiExpandable: () => false, expandedIds: this.inputs.value, @@ -253,7 +266,7 @@ export class TabListPattern { private _select(opts?: SelectOptions) { if (opts?.select) { this.selection.selectOne(); - this.expansionBehavior.open(); + this.expansionManager.open(); } }