diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index e67662f70f6a..4e15bbd10030 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -9,6 +9,7 @@ export const commitMessage: CommitMessageConfig = { minBodyLengthTypeExcludes: ['docs'], scopes: [ 'multiple', // For when a commit applies to multiple components. + 'cdk-experimental/accordion', 'cdk-experimental/column-resize', 'cdk-experimental/combobox', 'cdk-experimental/listbox', diff --git a/src/cdk-experimental/accordion/BUILD.bazel b/src/cdk-experimental/accordion/BUILD.bazel new file mode 100644 index 000000000000..14315b05848c --- /dev/null +++ b/src/cdk-experimental/accordion/BUILD.bazel @@ -0,0 +1,37 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "accordion", + srcs = [ + "accordion.ts", + "index.ts", + "public-api.ts", + ], + deps = [ + "//src/cdk-experimental/deferred-content", + "//src/cdk-experimental/ui-patterns", + "//src/cdk/a11y", + "//src/cdk/bidi", + ], +) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "accordion.spec.ts", + ], + deps = [ + ":accordion", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/accordion/accordion.spec.ts b/src/cdk-experimental/accordion/accordion.spec.ts new file mode 100644 index 000000000000..e7700c6de7b1 --- /dev/null +++ b/src/cdk-experimental/accordion/accordion.spec.ts @@ -0,0 +1,437 @@ +import {Component, DebugElement, signal, model} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {BidiModule} from '@angular/cdk/bidi'; +import {provideFakeDirectionality, runAccessibilityChecks} from '@angular/cdk/testing/private'; +import {_IdGenerator} from '@angular/cdk/a11y'; +import { + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, +} from './accordion'; + +describe('CdkAccordionGroup', () => { + let fixture: ComponentFixture; + let groupDebugElement: DebugElement; + let triggerDebugElements: DebugElement[]; + let panelDebugElements: DebugElement[]; + let groupInstance: CdkAccordionGroup; + let triggerElements: HTMLElement[]; + let panelElements: HTMLElement[]; + + const keydown = (target: HTMLElement, key: string) => { + target.dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, key})); + fixture.detectChanges(); + }; + + const click = (target: HTMLElement) => { + target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true})); + fixture.detectChanges(); + }; + + const spaceKey = (target: HTMLElement) => keydown(target, ' '); + const enterKey = (target: HTMLElement) => keydown(target, 'Enter'); + const downArrowKey = (target: HTMLElement) => keydown(target, 'ArrowDown'); + const upArrowKey = (target: HTMLElement) => keydown(target, 'ArrowUp'); + const homeKey = (target: HTMLElement) => keydown(target, 'Home'); + const endKey = (target: HTMLElement) => keydown(target, 'End'); + + interface SetupOptions { + initialValue?: string[]; + multiExpandable?: boolean; + disabledGroup?: boolean; + disabledItemValues?: string[]; + skipDisabled?: boolean; + wrap?: boolean; + } + + function configureAccordionComponent(opts: SetupOptions = {}) { + const testComponent = fixture.componentInstance as AccordionGroupExample; + + if (opts.initialValue !== undefined) { + testComponent.value.set(opts.initialValue); + } + if (opts.multiExpandable !== undefined) { + testComponent.multiExpandable.set(opts.multiExpandable); + } + if (opts.disabledGroup !== undefined) { + testComponent.disabledGroup.set(opts.disabledGroup); + } + if (opts.skipDisabled !== undefined) { + testComponent.skipDisabled.set(opts.skipDisabled); + } + if (opts.wrap !== undefined) { + testComponent.wrap.set(opts.wrap); + } + if (opts.disabledItemValues !== undefined) { + opts.disabledItemValues.forEach(value => testComponent.disableItem(value, true)); + } + + fixture.detectChanges(); + defineTestVariables(fixture); + } + + function defineTestVariables(currentFixture: ComponentFixture) { + groupDebugElement = currentFixture.debugElement.query(By.directive(CdkAccordionGroup)); + triggerDebugElements = currentFixture.debugElement.queryAll(By.directive(CdkAccordionTrigger)); + panelDebugElements = currentFixture.debugElement.queryAll(By.directive(CdkAccordionPanel)); + + groupInstance = groupDebugElement.injector.get(CdkAccordionGroup); + triggerElements = triggerDebugElements.map(el => el.nativeElement); + panelElements = panelDebugElements.map(el => el.nativeElement); + } + + function isTriggerActive(target: HTMLElement): boolean { + return target.classList.contains('cdk-active'); + } + + function isTriggerExpanded(target: HTMLElement): boolean { + return target.getAttribute('aria-expanded') === 'true'; + } + + afterEach(async () => { + await runAccessibilityChecks(fixture.nativeElement); + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [provideFakeDirectionality('ltr'), _IdGenerator], + imports: [BidiModule, AccordionGroupExample], + }).compileComponents(); + + fixture = TestBed.createComponent(AccordionGroupExample); + }); + + describe('ARIA attributes and roles', () => { + describe('CdkAccordionTrigger', () => { + beforeEach(() => { + configureAccordionComponent(); + }); + + it('should have role="button"', () => { + expect(triggerElements[0].getAttribute('role')).toBe('button'); + expect(triggerElements[1].getAttribute('role')).toBe('button'); + expect(triggerElements[2].getAttribute('role')).toBe('button'); + }); + + it('should have aria-expanded="false" when collapsed', () => { + configureAccordionComponent({initialValue: []}); + expect(triggerElements[0].getAttribute('aria-expanded')).toBe('false'); + expect(triggerElements[1].getAttribute('aria-expanded')).toBe('false'); + expect(triggerElements[2].getAttribute('aria-expanded')).toBe('false'); + }); + + it('should have aria-controls pointing to the panel id', () => { + expect(triggerElements[0].getAttribute('aria-controls')).toBe(panelElements[0].id); + expect(triggerElements[1].getAttribute('aria-controls')).toBe(panelElements[1].id); + expect(triggerElements[2].getAttribute('aria-controls')).toBe(panelElements[2].id); + }); + + it('should have aria-disabled="false" when not disabled', () => { + configureAccordionComponent({disabledItemValues: []}); + expect(triggerElements[0].getAttribute('aria-disabled')).toBe('false'); + expect(triggerElements[1].getAttribute('aria-disabled')).toBe('false'); + expect(triggerElements[2].getAttribute('aria-disabled')).toBe('false'); + }); + + it('should set aria-disabled="true" if trigger is disabled', () => { + configureAccordionComponent({disabledItemValues: ['item-1']}); + expect(triggerElements[0].getAttribute('aria-disabled')).toBe('true'); + expect(triggerElements[1].getAttribute('aria-disabled')).toBe('false'); + expect(triggerElements[2].getAttribute('aria-disabled')).toBe('false'); + }); + }); + + describe('CdkAccordionPanel', () => { + beforeEach(() => { + configureAccordionComponent(); + }); + + it('should have role="region"', () => { + expect(panelElements[0].getAttribute('role')).toBe('region'); + expect(panelElements[1].getAttribute('role')).toBe('region'); + expect(panelElements[2].getAttribute('role')).toBe('region'); + }); + + it('should have aria-labelledby pointing to the trigger id', () => { + expect(panelElements[0].getAttribute('aria-labelledby')).toBe(triggerElements[0].id); + expect(panelElements[1].getAttribute('aria-labelledby')).toBe(triggerElements[1].id); + expect(panelElements[2].getAttribute('aria-labelledby')).toBe(triggerElements[2].id); + }); + + it('should have "inert" attribute when collapsed', () => { + configureAccordionComponent({initialValue: []}); + expect(panelElements[0].hasAttribute('inert')).toBeTrue(); + expect(panelElements[1].hasAttribute('inert')).toBeTrue(); + expect(panelElements[2].hasAttribute('inert')).toBeTrue(); + }); + }); + }); + + describe('Expansion behavior', () => { + describe('single expansion mode (multiExpandable=false)', () => { + beforeEach(() => { + configureAccordionComponent({multiExpandable: false}); + }); + + it('should expand panel on trigger click and update value', () => { + click(triggerElements[0]); + expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + expect(panelElements[0].hasAttribute('inert')).toBeFalse(); + expect(groupInstance.value()).toEqual(['item-1']); + }); + + it('should collapes panel on trigger click and update value', () => { + click(triggerElements[0]); + click(triggerElements[0]); // Collapse + expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(panelElements[0].hasAttribute('inert')).toBeTrue(); + expect(groupInstance.value()).toEqual([]); + }); + + it('should expand one and collapse others', () => { + click(triggerElements[0]); + expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + expect(groupInstance.value()).toEqual(['item-1']); + + click(triggerElements[1]); + expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(panelElements[0].hasAttribute('inert')).toBeTrue(); + expect(isTriggerExpanded(triggerElements[1])).toBeTrue(); + expect(panelElements[1].hasAttribute('inert')).toBeFalse(); + expect(groupInstance.value()).toEqual(['item-2']); + }); + + it('should allow setting initial value', () => { + configureAccordionComponent({initialValue: ['item-2'], multiExpandable: false}); + expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(isTriggerExpanded(triggerElements[1])).toBeTrue(); + expect(isTriggerExpanded(triggerElements[2])).toBeFalse(); + }); + }); + + describe('multiple expansion mode (multiExpandable=true)', () => { + beforeEach(() => { + configureAccordionComponent({multiExpandable: true}); + }); + + it('should expand multiple panels', () => { + click(triggerElements[0]); + expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + + click(triggerElements[1]); + expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + expect(isTriggerExpanded(triggerElements[1])).toBeTrue(); + }); + + it('should collapse an item without affecting others', () => { + click(triggerElements[0]); + click(triggerElements[1]); + expect(groupInstance.value()).toEqual(jasmine.arrayWithExactContents(['item-1', 'item-2'])); + + click(triggerElements[0]); + expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(isTriggerExpanded(triggerElements[1])).toBeTrue(); + expect(groupInstance.value()).toEqual(['item-2']); + }); + + it('should allow setting initial multiple values', () => { + configureAccordionComponent({initialValue: ['item-1', 'item-3'], multiExpandable: true}); + expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + expect(isTriggerExpanded(triggerElements[1])).toBeFalse(); + expect(isTriggerExpanded(triggerElements[2])).toBeTrue(); + }); + }); + + describe('disabled items and group', () => { + it('should not expand a disabled trigger', () => { + configureAccordionComponent({disabledItemValues: ['item-1']}); + click(triggerElements[0]); + expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(groupInstance.value()).toEqual([]); + expect(triggerElements[0].getAttribute('aria-disabled')).toBe('true'); + }); + + it('should not expand any trigger if group is disabled', () => { + configureAccordionComponent({disabledGroup: true}); + click(triggerElements[0]); + expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(groupInstance.value()).toEqual([]); + click(triggerElements[1]); + expect(isTriggerExpanded(triggerElements[1])).toBeFalse(); + }); + }); + }); + + describe('Keyboard navigation and interaction', () => { + beforeEach(() => { + configureAccordionComponent({}); + + // Focus on the first trigger as initial state. + triggerElements[0].focus(); + fixture.detectChanges(); + expect(isTriggerActive(triggerElements[0])).toBeTrue(); + }); + + it('should focus next trigger with ArrowDown', () => { + downArrowKey(triggerElements[0]); + expect(isTriggerActive(triggerElements[0])).toBeFalse(); + expect(isTriggerActive(triggerElements[1])).toBeTrue(); + }); + + it('should focus previous trigger with ArrowUp', () => { + downArrowKey(triggerElements[0]); + expect(isTriggerActive(triggerElements[1])).toBeTrue(); + upArrowKey(triggerElements[1]); + expect(isTriggerActive(triggerElements[1])).toBeFalse(); + expect(isTriggerActive(triggerElements[0])).toBeTrue(); + }); + + it('should focus first trigger with Home when another item is focused', () => { + downArrowKey(triggerElements[0]); + downArrowKey(triggerElements[1]); + expect(isTriggerActive(triggerElements[2])).toBeTrue(); + homeKey(triggerElements[2]); + expect(isTriggerActive(triggerElements[0])).toBeTrue(); + }); + + it('should focus last trigger with End', () => { + endKey(triggerElements[0]); + expect(isTriggerActive(triggerElements[2])).toBeTrue(); + }); + + it('should toggle expansion of focused trigger with Enter', () => { + expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + enterKey(triggerElements[0]); + expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + enterKey(triggerElements[0]); + expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + }); + + it('should toggle expansion of focused trigger with Space', () => { + expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + spaceKey(triggerElements[0]); + expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + spaceKey(triggerElements[0]); + expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + }); + + describe('wrap behavior', () => { + it('should wrap to first on ArrowDown from last if wrap=true', () => { + configureAccordionComponent({wrap: true}); + endKey(triggerElements[0]); + expect(isTriggerActive(triggerElements[2])).toBeTrue(); + downArrowKey(triggerElements[2]); + expect(isTriggerActive(triggerElements[0])).toBeTrue(); + }); + + it('should not wrap on ArrowDown from last if wrap=false', () => { + configureAccordionComponent({wrap: false}); + endKey(triggerElements[0]); + expect(isTriggerActive(triggerElements[2])).toBeTrue(); + downArrowKey(triggerElements[2]); + expect(isTriggerActive(triggerElements[2])).toBeTrue(); + }); + + it('should wrap to last on ArrowUp from first if wrap=true', () => { + configureAccordionComponent({wrap: true}); + expect(isTriggerActive(triggerElements[0])).toBeTrue(); + upArrowKey(triggerElements[0]); + expect(isTriggerActive(triggerElements[2])).toBeTrue(); + }); + + it('should not wrap on ArrowUp from first if wrap=false', () => { + configureAccordionComponent({wrap: false}); + expect(isTriggerActive(triggerElements[0])).toBeTrue(); + upArrowKey(triggerElements[0]); + expect(isTriggerActive(triggerElements[0])).toBeTrue(); + }); + }); + + describe('skipDisabled behavior', () => { + it('should skip disabled items if skipDisabled=true', () => { + configureAccordionComponent({skipDisabled: true, disabledItemValues: ['item-2']}); + + expect(isTriggerActive(triggerElements[0])).toBeTrue(); + downArrowKey(triggerElements[0]); + expect(isTriggerActive(triggerElements[2])).toBeTrue(); + }); + + it('should focus disabled items if skipDisabled=false', () => { + configureAccordionComponent({skipDisabled: false, disabledItemValues: ['item-2']}); + + expect(isTriggerActive(triggerElements[0])).toBeTrue(); + downArrowKey(triggerElements[0]); + expect(isTriggerActive(triggerElements[1])).toBeTrue(); + enterKey(triggerElements[1]); + expect(isTriggerExpanded(triggerElements[1])).toBeFalse(); + }); + }); + + it('should not allow keyboard navigation if group is disabled', () => { + configureAccordionComponent({disabledGroup: true}); + + downArrowKey(triggerElements[0]); + expect(isTriggerActive(triggerElements[1])).toBeFalse(); + }); + + it('should not allow expansion if group is disabled', () => { + configureAccordionComponent({disabledGroup: true}); + + enterKey(triggerElements[0]); + expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + }); + }); +}); + +@Component({ + template: ` +
+ @for (item of items(); track item.value) { +
+ +
+ + {{ item.content }} + +
+
+ } +
+ `, + imports: [CdkAccordionGroup, CdkAccordionTrigger, CdkAccordionPanel, CdkAccordionContent], +}) +class AccordionGroupExample { + items = signal([ + {value: 'item-1', header: 'Item 1 Header', content: 'Item 1 Content', disabled: false}, + {value: 'item-2', header: 'Item 2 Header', content: 'Item 2 Content', disabled: false}, + {value: 'item-3', header: 'Item 3 Header', content: 'Item 3 Content', disabled: false}, + ]); + + value = model([]); + multiExpandable = signal(false); + disabledGroup = signal(false); + skipDisabled = signal(true); + wrap = signal(false); + + disableItem(itemValue: string, disabled: boolean) { + this.items.update(items => + items.map(item => (item.value === itemValue ? {...item, disabled} : item)), + ); + } +} diff --git a/src/cdk-experimental/accordion/accordion.ts b/src/cdk-experimental/accordion/accordion.ts new file mode 100644 index 000000000000..670b94687bf1 --- /dev/null +++ b/src/cdk-experimental/accordion/accordion.ts @@ -0,0 +1,205 @@ +/** + * @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 { + Directive, + input, + ElementRef, + inject, + contentChildren, + afterRenderEffect, + signal, + model, + booleanAttribute, + computed, + WritableSignal, +} from '@angular/core'; +import {_IdGenerator} from '@angular/cdk/a11y'; +import {Directionality} from '@angular/cdk/bidi'; +import {DeferredContent, DeferredContentAware} from '@angular/cdk-experimental/deferred-content'; +import { + AccordionGroupPattern, + AccordionPanelPattern, + AccordionTriggerPattern, +} from '../ui-patterns/'; + +/** + * Represents the content panel of an accordion item. It is controlled by an + * associated `CdkAccordionTrigger`. + */ +@Directive({ + selector: '[cdkAccordionPanel]', + exportAs: 'cdkAccordionPanel', + hostDirectives: [ + { + directive: DeferredContentAware, + inputs: ['preserveContent'], + }, + ], + host: { + 'class': 'cdk-accordion-panel', + 'role': 'region', + '[attr.id]': 'pattern.id()', + '[attr.aria-labelledby]': 'pattern.accordionTrigger()?.id()', + '[attr.inert]': 'pattern.hidden() ? true : null', + }, +}) +export class CdkAccordionPanel { + /** The DeferredContentAware host directive. */ + private readonly _deferredContentAware = inject(DeferredContentAware); + + /** A global unique identifier for the panel. */ + private readonly _id = inject(_IdGenerator).getId('cdk-accordion-trigger-'); + + /** A local unique identifier for the panel, used to match with its trigger's value. */ + value = input.required(); + + /** The parent accordion trigger pattern that controls this panel. This is set by CdkAccordionGroup. */ + readonly accordionTrigger: WritableSignal = + signal(undefined); + + /** The UI pattern instance for this panel. */ + readonly pattern: AccordionPanelPattern = new AccordionPanelPattern({ + id: () => this._id, + value: this.value, + accordionTrigger: () => this.accordionTrigger(), + }); + + constructor() { + // Connect the panel's hidden state to the DeferredContentAware's visibility. + afterRenderEffect(() => { + this._deferredContentAware.contentVisible.set(!this.pattern.hidden()); + }); + } +} + +/** + * Represents the trigger button for an accordion item. It controls the expansion + * state of an associated `CdkAccordionPanel`. + */ +@Directive({ + selector: '[cdkAccordionTrigger]', + exportAs: 'cdkAccordionTrigger', + host: { + 'class': 'cdk-accordion-trigger', + '[class.cdk-active]': 'pattern.active()', + 'role': 'button', + '[id]': 'pattern.id()', + '[attr.aria-expanded]': 'pattern.expanded()', + '[attr.aria-controls]': 'pattern.controls()', + '[attr.aria-disabled]': 'pattern.disabled()', + '[attr.tabindex]': 'pattern.tabindex()', + '(keydown)': 'pattern.onKeydown($event)', + '(pointerdown)': 'pattern.onPointerdown($event)', + '(focusin)': 'pattern.onFocus($event)', + }, +}) +export class CdkAccordionTrigger { + /** A global unique identifier for the trigger. */ + private readonly _id = inject(_IdGenerator).getId('cdk-accordion-trigger-'); + + /** A reference to the trigger element. */ + private readonly _elementRef = inject(ElementRef); + + /** The parent CdkAccordionGroup. */ + private readonly _accordionGroup = inject(CdkAccordionGroup); + + /** A local unique identifier for the trigger, used to match with its panel's value. */ + value = input.required(); + + /** Whether the trigger is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** The accordion panel pattern controlled by this trigger. This is set by CdkAccordionGroup. */ + readonly accordionPanel: WritableSignal = signal(undefined); + + /** The UI pattern instance for this trigger. */ + readonly pattern: AccordionTriggerPattern = new AccordionTriggerPattern({ + id: () => this._id, + value: this.value, + disabled: this.disabled, + element: () => this._elementRef.nativeElement, + accordionGroup: computed(() => this._accordionGroup.pattern), + accordionPanel: this.accordionPanel, + }); +} + +/** + * Container for a group of accordion items. It manages the overall state and + * interactions of the accordion, such as keyboard navigation and expansion mode. + */ +@Directive({ + selector: '[cdkAccordionGroup]', + exportAs: 'cdkAccordionGroup', + host: { + 'class': 'cdk-accordion-group', + }, +}) +export class CdkAccordionGroup { + /** The CdkAccordionTriggers nested inside this group. */ + protected readonly _triggers = contentChildren(CdkAccordionTrigger, {descendants: true}); + + /** The CdkAccordionPanels nested inside this group. */ + protected readonly _panels = contentChildren(CdkAccordionPanel, {descendants: true}); + + /** The text direction (ltr or rtl). */ + readonly textDirection = inject(Directionality).valueSignal; + + /** Whether the entire accordion group is disabled. */ + disabled = input(false, {transform: booleanAttribute}); + + /** Whether multiple accordion items can be expanded simultaneously. */ + multiExpandable = input(true, {transform: booleanAttribute}); + + /** The values of the current selected/expanded accordions. */ + value = model([]); + + /** Whether disabled items should be skipped during keyboard navigation. */ + skipDisabled = input(true, {transform: booleanAttribute}); + + /** Whether keyboard navigation should wrap around from the last item to the first, and vice-versa. */ + wrap = input(false, {transform: booleanAttribute}); + + /** The UI pattern instance for this accordion group. */ + readonly pattern: AccordionGroupPattern = new AccordionGroupPattern({ + ...this, + // TODO(ok7sai): Consider making `activeIndex` an internal state in the pattern and call + // `setDefaultState` in the CDK. + activeIndex: signal(0), + items: computed(() => this._triggers().map(trigger => trigger.pattern)), + expandedIds: this.value, + // TODO(ok7sai): Investigate whether an accordion should support horizontal mode. + orientation: () => 'vertical', + }); + + constructor() { + // Effect to link triggers with their corresponding panels and update the group's items. + afterRenderEffect(() => { + const triggers = this._triggers(); + const panels = this._panels(); + + for (const trigger of triggers) { + const panel = panels.find(p => p.value() === trigger.value()); + trigger.accordionPanel.set(panel?.pattern); + if (panel) { + panel.accordionTrigger.set(trigger.pattern); + } + } + }); + } +} + +/** + * A structural directive that marks the `ng-template` to be used as the content + * for a `CdkAccordionPanel`. This content can be lazily loaded. + */ +@Directive({ + selector: 'ng-template[cdkAccordionContent]', + hostDirectives: [DeferredContent], +}) +export class CdkAccordionContent {} diff --git a/src/cdk-experimental/accordion/index.ts b/src/cdk-experimental/accordion/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/cdk-experimental/accordion/index.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export * from './public-api'; diff --git a/src/cdk-experimental/accordion/public-api.ts b/src/cdk-experimental/accordion/public-api.ts new file mode 100644 index 000000000000..0724db6c960b --- /dev/null +++ b/src/cdk-experimental/accordion/public-api.ts @@ -0,0 +1,14 @@ +/** + * @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 + */ + +export { + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, +} from './accordion'; diff --git a/src/cdk-experimental/config.bzl b/src/cdk-experimental/config.bzl index 9e3cac5be0eb..aa8124d0707c 100644 --- a/src/cdk-experimental/config.bzl +++ b/src/cdk-experimental/config.bzl @@ -1,5 +1,6 @@ # List of all entry-points of the Angular cdk-experimental package. CDK_EXPERIMENTAL_ENTRYPOINTS = [ + "accordion", "column-resize", "combobox", "deferred-content", diff --git a/src/cdk-experimental/ui-patterns/BUILD.bazel b/src/cdk-experimental/ui-patterns/BUILD.bazel index 4b8299b571ee..29e68600535a 100644 --- a/src/cdk-experimental/ui-patterns/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/BUILD.bazel @@ -10,6 +10,7 @@ ts_project( ), deps = [ "//:node_modules/@angular/core", + "//src/cdk-experimental/ui-patterns/accordion", "//src/cdk-experimental/ui-patterns/behaviors/signal-like", "//src/cdk-experimental/ui-patterns/listbox", "//src/cdk-experimental/ui-patterns/radio", diff --git a/src/cdk-experimental/ui-patterns/accordion/accordion.ts b/src/cdk-experimental/ui-patterns/accordion/accordion.ts index 7258d5973a3a..2d378148fe47 100644 --- a/src/cdk-experimental/ui-patterns/accordion/accordion.ts +++ b/src/cdk-experimental/ui-patterns/accordion/accordion.ts @@ -107,9 +107,15 @@ export class AccordionTriggerPattern { /** Id of the accordion panel controlled by the trigger. */ controls = computed(() => this.inputs.accordionPanel()?.id()); + /** The tabindex of the trigger. */ + tabindex = computed(() => (this.inputs.accordionGroup().focusManager.isFocusable(this) ? 0 : -1)); + + /** Whether the trigger is disabled. Disabling an accordion group disables all the triggers. */ + disabled = computed(() => this.inputs.disabled() || this.inputs.accordionGroup().disabled()); + constructor(readonly inputs: AccordionTriggerInputs) { + this.id = inputs.id; this.element = inputs.element; - this.disabled = inputs.disabled; this.value = inputs.value; this.accordionGroup = inputs.accordionGroup; this.accordionPanel = inputs.accordionPanel; @@ -173,7 +179,16 @@ export class AccordionTriggerPattern { this.pointerdown().handle(event); } - private _getItem(e: PointerEvent) { + /** Handles focus events on the trigger. This ensures the tabbing changes the active index. */ + onFocus(event: FocusEvent): void { + const item = this._getItem(event); + + if (item && this.inputs.accordionGroup().focusManager.isFocusable(item)) { + this.accordionGroup().focusManager.focus(item); + } + } + + private _getItem(e: Event) { if (!(e.target instanceof HTMLElement)) { return; } 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 bd9dafdf24f4..0610f5a7c5d3 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.spec.ts @@ -242,10 +242,10 @@ describe('Expansion', () => { expect(expansion.isExpandable(items[0])).toBeTrue(); }); - it('should return true if an item is disabled and skipDisabled is false', () => { + it('should return false 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(); + expect(expansion.isExpandable(items[0])).toBeFalse(); }); it('should return false if an item is disabled and skipDisabled is true', () => { diff --git a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts index 6c5824f590f8..c2363c8e4447 100644 --- a/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts +++ b/src/cdk-experimental/ui-patterns/behaviors/expansion/expansion.ts @@ -76,11 +76,12 @@ export class ListExpansion { /** 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()]); + if (!this.isExpandable(item)) return; + if (this.isExpanded(item)) return; + if (!this.inputs.multiExpandable()) { + this.closeAll(); } + this.expandedIds.update(ids => ids.concat(item.expansionId())); } /** Closes the specified item, or the currently active item if none is specified. */ @@ -116,9 +117,7 @@ export class ListExpansion { /** Checks whether the specified item is expandable / collapsible. */ isExpandable(item: T) { - return ( - !this.inputs.disabled() && this.inputs.focusManager.isFocusable(item) && item.expandable() - ); + return !this.inputs.disabled() && !item.disabled() && item.expandable(); } /** Checks whether the specified item is currently expanded. */ diff --git a/src/cdk-experimental/ui-patterns/public-api.ts b/src/cdk-experimental/ui-patterns/public-api.ts index 06383ea9b5bf..6bb8ba2337c5 100644 --- a/src/cdk-experimental/ui-patterns/public-api.ts +++ b/src/cdk-experimental/ui-patterns/public-api.ts @@ -12,3 +12,4 @@ export * from './radio/radio-group'; export * from './radio/radio'; export * from './behaviors/signal-like/signal-like'; export * from './tabs/tabs'; +export * from './accordion/accordion'; diff --git a/src/components-examples/cdk-experimental/accordion/BUILD.bazel b/src/components-examples/cdk-experimental/accordion/BUILD.bazel new file mode 100644 index 000000000000..f94820f50f63 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/BUILD.bazel @@ -0,0 +1,29 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "accordion", + srcs = glob(["**/*.ts"]), + assets = glob([ + "**/*.html", + "**/*.css", + ]), + deps = [ + "//src/cdk-experimental/accordion", + "//src/material/checkbox", + "//src/material/form-field", + "//src/material/icon", + "//src/material/input", + "//src/material/select", + ], +) + +filegroup( + name = "source-files", + srcs = glob([ + "**/*.html", + "**/*.css", + "**/*.ts", + ]), +) diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion/cdk-accordion-example.css b/src/components-examples/cdk-experimental/accordion/cdk-accordion/cdk-accordion-example.css new file mode 100644 index 000000000000..d0ae603e449d --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion/cdk-accordion-example.css @@ -0,0 +1,53 @@ +.example-accordion-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + padding-bottom: 16px; + margin-bottom: 16px; +} + +.example-accordion-header { + margin: 0; +} + +.example-accordion-header .cdk-accordion-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 12px 16px; + border: 1px solid transparent; + background-color: var(--mat-sys-surface); + cursor: pointer; + border-radius: 4px; + margin-bottom: 4px; +} + +.example-accordion-header .cdk-accordion-trigger:hover { + background-color: var(--mat-sys-surface-container-highest); +} + +.example-accordion-header .cdk-accordion-trigger.cdk-active { + background-color: var(--mat-sys-surface-dim); +} + +.example-accordion-header .cdk-accordion-trigger:focus { + outline: 2px solid var(--mat-sys-primary); + outline-offset: -2px; +} + +.example-accordion-header .cdk-accordion-trigger[aria-disabled='true'] { + color: inherit; + opacity: 0.6; +} + +.example-accordion-panel { + padding: 16px; + background-color: var(--mat-sys-surface-container); + margin-bottom: 4px; +} + +.example-accordion-panel[inert] { + display: none; +} diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion/cdk-accordion-example.html b/src/components-examples/cdk-experimental/accordion/cdk-accordion/cdk-accordion-example.html new file mode 100644 index 000000000000..0abc4628c262 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion/cdk-accordion-example.html @@ -0,0 +1,73 @@ +
+ Wrap (ArrowKey-only) + Multi + Disabled + Skip Disabled + + + Expanded Items + + @for (item of items; track item) { + {{item}} + } + + +
+ +
+
+

+ +

+
+ +

This is the content for Item 1.

+ +
+
+
+ +
+

+ +

+
+ +

This is the content for Item 2.

+ +
+
+
+ +
+

+ +

+
+ +

This is the content for Item 3.

+
+
+
+
diff --git a/src/components-examples/cdk-experimental/accordion/cdk-accordion/cdk-accordion-example.ts b/src/components-examples/cdk-experimental/accordion/cdk-accordion/cdk-accordion-example.ts new file mode 100644 index 000000000000..5f810bd34ef2 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/cdk-accordion/cdk-accordion-example.ts @@ -0,0 +1,48 @@ +import {Component, computed, model, Signal} from '@angular/core'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {MatSelectModule} from '@angular/material/select'; +import {MatIconModule} from '@angular/material/icon'; +import { + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, +} from '@angular/cdk-experimental/accordion'; + +/** @title Accordion using UI Patterns. */ +@Component({ + selector: 'cdk-accordion-example', + exportAs: 'cdkAccordionExample', + templateUrl: 'cdk-accordion-example.html', + styleUrl: 'cdk-accordion-example.css', + imports: [ + ReactiveFormsModule, + MatIconModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + CdkAccordionGroup, + CdkAccordionTrigger, + CdkAccordionPanel, + CdkAccordionContent, + ], +}) +export class CdkAccordionExample { + // Accordion Group Properties + wrap = new FormControl(true, {nonNullable: true}); + multi = new FormControl(true, {nonNullable: true}); + disabled = new FormControl(false, {nonNullable: true}); + skipDisabled = new FormControl(true, {nonNullable: true}); + expandedIds = model(['item1']); + + // Example items + items = ['item1', 'item2', 'item3']; + + expansionIcon(item: string): Signal { + return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); + } +} diff --git a/src/components-examples/cdk-experimental/accordion/index.ts b/src/components-examples/cdk-experimental/accordion/index.ts new file mode 100644 index 000000000000..d35cda000da3 --- /dev/null +++ b/src/components-examples/cdk-experimental/accordion/index.ts @@ -0,0 +1 @@ +export {CdkAccordionExample} from './cdk-accordion/cdk-accordion-example'; diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 8073332cd43f..199684c45d76 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -33,6 +33,7 @@ ng_project( "//src/dev-app/button-toggle", "//src/dev-app/card", "//src/dev-app/cdk-dialog", + "//src/dev-app/cdk-experimental-accordion", "//src/dev-app/cdk-experimental-combobox", "//src/dev-app/cdk-experimental-listbox", "//src/dev-app/cdk-experimental-tabs", diff --git a/src/dev-app/cdk-experimental-accordion/BUILD.bazel b/src/dev-app/cdk-experimental-accordion/BUILD.bazel new file mode 100644 index 000000000000..2c66553572b3 --- /dev/null +++ b/src/dev-app/cdk-experimental-accordion/BUILD.bazel @@ -0,0 +1,10 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "cdk-experimental-accordion", + srcs = glob(["**/*.ts"]), + assets = ["cdk-accordion-demo.html"], + deps = ["//src/components-examples/cdk-experimental/accordion"], +) diff --git a/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.html b/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.html new file mode 100644 index 000000000000..2f7e734d696d --- /dev/null +++ b/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.html @@ -0,0 +1,4 @@ +
+

Accordion using UI Patterns

+ +
diff --git a/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.ts b/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.ts new file mode 100644 index 000000000000..c4f22c58d973 --- /dev/null +++ b/src/dev-app/cdk-experimental-accordion/cdk-accordion-demo.ts @@ -0,0 +1,17 @@ +/** + * @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 {ChangeDetectionStrategy, Component} from '@angular/core'; +import {CdkAccordionExample} from '@angular/components-examples/cdk-experimental/accordion'; + +@Component({ + templateUrl: 'cdk-accordion-demo.html', + imports: [CdkAccordionExample], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkExperimentalAccordionDemo {} diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 12a9f56de8f0..21bdd2d72b75 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -62,6 +62,7 @@ export class DevAppLayout { {name: 'CDK Experimental Combobox', route: '/cdk-experimental-combobox'}, {name: 'CDK Experimental Listbox', route: '/cdk-experimental-listbox'}, {name: 'CDK Experimental Tabs', route: '/cdk-experimental-tabs'}, + {name: 'CDK Experimental Accordion', route: '/cdk-experimental-accordion'}, {name: 'CDK Listbox', route: '/cdk-listbox'}, {name: 'CDK Menu', route: '/cdk-menu'}, {name: 'Autocomplete', route: '/autocomplete'}, diff --git a/src/dev-app/routes.ts b/src/dev-app/routes.ts index bc01db461312..c311f4e4396c 100644 --- a/src/dev-app/routes.ts +++ b/src/dev-app/routes.ts @@ -55,6 +55,13 @@ export const DEV_APP_ROUTES: Routes = [ loadComponent: () => import('./cdk-experimental-tabs/cdk-tabs-demo').then(m => m.CdkExperimentalTabsDemo), }, + { + path: 'cdk-experimental-accordion', + loadComponent: () => + import('./cdk-experimental-accordion/cdk-accordion-demo').then( + m => m.CdkExperimentalAccordionDemo, + ), + }, { path: 'cdk-dialog', loadComponent: () => import('./cdk-dialog/dialog-demo').then(m => m.DialogDemo),