From 1c9bbe408eebf7e04c925ae6e8b485c5d213b293 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Thu, 2 Apr 2026 14:23:38 -0700 Subject: [PATCH 1/6] refactor(multiple): add utils to aria/private and consolidate sortDirectives usage --- src/aria/private/BUILD.bazel | 1 + src/aria/private/public-api.ts | 1 + src/aria/private/utils/BUILD.bazel | 8 ++++++++ .../{tree/utils.ts => private/utils/element.ts} | 0 src/aria/tabs/tab-list.ts | 4 ++-- src/aria/tabs/tab-panel.ts | 2 +- src/aria/tabs/{utils.ts => tab-tokens.ts} | 13 ------------- src/aria/tabs/tab.ts | 4 ++-- src/aria/tabs/tabs.ts | 2 +- src/aria/toolbar/{utils.ts => toolbar-tokens.ts} | 10 ---------- src/aria/toolbar/toolbar-widget-group.ts | 2 +- src/aria/toolbar/toolbar-widget.ts | 2 +- src/aria/toolbar/toolbar.ts | 3 +-- src/aria/tree/tree-item-group.ts | 3 +-- src/aria/tree/tree-item.ts | 3 +-- src/aria/tree/tree.ts | 3 +-- 16 files changed, 22 insertions(+), 39 deletions(-) create mode 100644 src/aria/private/utils/BUILD.bazel rename src/aria/{tree/utils.ts => private/utils/element.ts} (100%) rename src/aria/tabs/{utils.ts => tab-tokens.ts} (58%) rename src/aria/toolbar/{utils.ts => toolbar-tokens.ts} (68%) diff --git a/src/aria/private/BUILD.bazel b/src/aria/private/BUILD.bazel index f688ab1b20e1..22b2130483dc 100644 --- a/src/aria/private/BUILD.bazel +++ b/src/aria/private/BUILD.bazel @@ -20,5 +20,6 @@ ts_project( "//src/aria/private/tabs", "//src/aria/private/toolbar", "//src/aria/private/tree", + "//src/aria/private/utils", ], ) diff --git a/src/aria/private/public-api.ts b/src/aria/private/public-api.ts index ed8716c7b67b..0b402dd342a6 100644 --- a/src/aria/private/public-api.ts +++ b/src/aria/private/public-api.ts @@ -25,3 +25,4 @@ export * from './grid/row'; export * from './grid/cell'; export * from './grid/widget'; export * from './deferred-content'; +export * from './utils/element'; diff --git a/src/aria/private/utils/BUILD.bazel b/src/aria/private/utils/BUILD.bazel new file mode 100644 index 000000000000..765abd4edc7c --- /dev/null +++ b/src/aria/private/utils/BUILD.bazel @@ -0,0 +1,8 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "utils", + srcs = ["element.ts"], +) diff --git a/src/aria/tree/utils.ts b/src/aria/private/utils/element.ts similarity index 100% rename from src/aria/tree/utils.ts rename to src/aria/private/utils/element.ts diff --git a/src/aria/tabs/tab-list.ts b/src/aria/tabs/tab-list.ts index 9932a6207aec..d9fda7adb7ce 100644 --- a/src/aria/tabs/tab-list.ts +++ b/src/aria/tabs/tab-list.ts @@ -20,8 +20,8 @@ import { OnInit, OnDestroy, } from '@angular/core'; -import {TabListPattern, TabPattern} from '../private'; -import {sortDirectives, TABS} from './utils'; +import {TabListPattern, TabPattern, sortDirectives} from '../private'; +import {TABS} from './tab-tokens'; import type {Tab} from './tab'; /** diff --git a/src/aria/tabs/tab-panel.ts b/src/aria/tabs/tab-panel.ts index 31f594e7443c..13b5513c662f 100644 --- a/src/aria/tabs/tab-panel.ts +++ b/src/aria/tabs/tab-panel.ts @@ -18,7 +18,7 @@ import { OnDestroy, } from '@angular/core'; import {TabPanelPattern, DeferredContentAware} from '../private'; -import {TABS} from './utils'; +import {TABS} from './tab-tokens'; /** * A TabPanel container for the resources of layered content associated with a tab. diff --git a/src/aria/tabs/utils.ts b/src/aria/tabs/tab-tokens.ts similarity index 58% rename from src/aria/tabs/utils.ts rename to src/aria/tabs/tab-tokens.ts index 8a00da60c027..83b3ea089a6e 100644 --- a/src/aria/tabs/utils.ts +++ b/src/aria/tabs/tab-tokens.ts @@ -11,16 +11,3 @@ import type {Tabs} from './tabs'; /** Token used to expose the `Tabs` directive to child directives. */ export const TABS = new InjectionToken('TABS'); - -export interface HasElement { - element: HTMLElement; -} - -/** - * Sort directives by their document order. - */ -export function sortDirectives(a: HasElement, b: HasElement) { - return (a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_PRECEDING) > 0 - ? 1 - : -1; -} diff --git a/src/aria/tabs/tab.ts b/src/aria/tabs/tab.ts index fcc6c07a5763..c446cec5a17a 100644 --- a/src/aria/tabs/tab.ts +++ b/src/aria/tabs/tab.ts @@ -18,9 +18,9 @@ import { OnInit, OnDestroy, } from '@angular/core'; -import {TabPattern} from '../private'; +import {TabPattern, HasElement} from '../private'; import {TabList} from './tab-list'; -import {HasElement, TABS} from './utils'; +import {TABS} from './tab-tokens'; /** * A selectable tab in a TabList. diff --git a/src/aria/tabs/tabs.ts b/src/aria/tabs/tabs.ts index 0c8d616f411e..33600a1c38b0 100644 --- a/src/aria/tabs/tabs.ts +++ b/src/aria/tabs/tabs.ts @@ -9,7 +9,7 @@ import {computed, Directive, ElementRef, inject, signal} from '@angular/core'; import {TabList} from './tab-list'; import {TabPanel} from './tab-panel'; -import {TABS} from './utils'; +import {TABS} from './tab-tokens'; import {TabPanelPattern, TabPattern} from '../private'; /** diff --git a/src/aria/toolbar/utils.ts b/src/aria/toolbar/toolbar-tokens.ts similarity index 68% rename from src/aria/toolbar/utils.ts rename to src/aria/toolbar/toolbar-tokens.ts index 558f8508fde7..3e3c083a8cb0 100644 --- a/src/aria/toolbar/utils.ts +++ b/src/aria/toolbar/toolbar-tokens.ts @@ -13,13 +13,3 @@ import type {ToolbarWidgetGroup} from './toolbar-widget-group'; export const TOOLBAR_WIDGET_GROUP = new InjectionToken>( 'TOOLBAR_WIDGET_GROUP', ); - -interface HasElement { - element: HTMLElement; -} - -export function sortDirectives(a: HasElement, b: HasElement) { - return (a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_PRECEDING) > 0 - ? 1 - : -1; -} diff --git a/src/aria/toolbar/toolbar-widget-group.ts b/src/aria/toolbar/toolbar-widget-group.ts index 01a76ec848cd..5c73b44f38a6 100644 --- a/src/aria/toolbar/toolbar-widget-group.ts +++ b/src/aria/toolbar/toolbar-widget-group.ts @@ -18,7 +18,7 @@ import { import {ToolbarWidgetPattern, ToolbarWidgetGroupPattern} from '../private'; import {Toolbar} from './toolbar'; import {ToolbarWidget} from './toolbar-widget'; -import {TOOLBAR_WIDGET_GROUP} from './utils'; +import {TOOLBAR_WIDGET_GROUP} from './toolbar-tokens'; /** * A directive that groups toolbar widgets, used for more complex widgets like radio groups diff --git a/src/aria/toolbar/toolbar-widget.ts b/src/aria/toolbar/toolbar-widget.ts index 76d632952607..9a255da51890 100644 --- a/src/aria/toolbar/toolbar-widget.ts +++ b/src/aria/toolbar/toolbar-widget.ts @@ -24,7 +24,7 @@ import { } from '../private'; import {_IdGenerator} from '@angular/cdk/a11y'; import {Toolbar} from './toolbar'; -import {TOOLBAR_WIDGET_GROUP} from './utils'; +import {TOOLBAR_WIDGET_GROUP} from './toolbar-tokens'; import type {ToolbarWidgetGroup} from './toolbar-widget-group'; /** diff --git a/src/aria/toolbar/toolbar.ts b/src/aria/toolbar/toolbar.ts index bbb877b58129..8e2964884397 100644 --- a/src/aria/toolbar/toolbar.ts +++ b/src/aria/toolbar/toolbar.ts @@ -17,10 +17,9 @@ import { signal, model, } from '@angular/core'; -import {ToolbarPattern, ToolbarWidgetPattern} from '../private'; +import {ToolbarPattern, ToolbarWidgetPattern, sortDirectives} from '../private'; import {Directionality} from '@angular/cdk/bidi'; import type {ToolbarWidget} from './toolbar-widget'; -import {sortDirectives} from './utils'; /** * A toolbar widget container for a group of interactive widgets, such as diff --git a/src/aria/tree/tree-item-group.ts b/src/aria/tree/tree-item-group.ts index 01be388d4de1..d20cb200972f 100644 --- a/src/aria/tree/tree-item-group.ts +++ b/src/aria/tree/tree-item-group.ts @@ -16,9 +16,8 @@ import { OnInit, OnDestroy, } from '@angular/core'; -import {TreeItemPattern, DeferredContent} from '../private'; +import {TreeItemPattern, DeferredContent, sortDirectives} from '../private'; import type {TreeItem} from './tree-item'; -import {sortDirectives} from './utils'; /** * Group that contains children tree items. diff --git a/src/aria/tree/tree-item.ts b/src/aria/tree/tree-item.ts index 728ec42701d5..78aadaf1bb1b 100644 --- a/src/aria/tree/tree-item.ts +++ b/src/aria/tree/tree-item.ts @@ -22,10 +22,9 @@ import { afterNextRender, } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; -import {ComboboxTreePattern, TreeItemPattern, DeferredContentAware} from '../private'; +import {ComboboxTreePattern, TreeItemPattern, DeferredContentAware, HasElement} from '../private'; import {Tree} from './tree'; import {TreeItemGroup} from './tree-item-group'; -import {HasElement} from './utils'; /** * A selectable and expandable item in an `ngTree`. diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index c50465478b8d..31de2e750c88 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -20,10 +20,9 @@ import { } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; -import {ComboboxTreePattern, TreeItemPattern, TreePattern} from '../private'; +import {ComboboxTreePattern, TreeItemPattern, TreePattern, sortDirectives} from '../private'; import {ComboboxPopup} from '../combobox'; import type {TreeItem} from './tree-item'; -import {sortDirectives} from './utils'; /** * A container that transforms nested lists into an accessible, ARIA-compliant tree structure. From ef3f55f1e5f652a9b1c7c442966f799f20b2d955 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 6 Apr 2026 16:58:17 -0400 Subject: [PATCH 2/6] refactor(aria/accordion): Add element reference to AccordionPanel --- goldens/aria/accordion/index.api.md | 1 + goldens/aria/private/index.api.md | 9 +++++++++ src/aria/accordion/accordion-panel.ts | 8 +++++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/goldens/aria/accordion/index.api.md b/goldens/aria/accordion/index.api.md index df89ab8ce58d..011f31ca0590 100644 --- a/goldens/aria/accordion/index.api.md +++ b/goldens/aria/accordion/index.api.md @@ -38,6 +38,7 @@ export class AccordionGroup { export class AccordionPanel { constructor(); collapse(): void; + readonly element: HTMLElement; expand(): void; readonly id: _angular_core.InputSignal; _pattern?: AccordionTriggerPattern; diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index f484be549c6c..9a367cb10791 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -437,6 +437,12 @@ export class GridRowPattern { rowIndex: SignalLike; } +// @public (undocumented) +export interface HasElement { + // (undocumented) + element: HTMLElement; +} + // @public (undocumented) export function linkedSignal(sourceFn: () => T): WritableSignalLike; @@ -669,6 +675,9 @@ export function signal(initialValue: T): WritableSignalLike; // @public (undocumented) export type SignalLike = () => T; +// @public +export function sortDirectives(a: HasElement, b: HasElement): 1 | -1; + // @public export interface TabInputs extends Omit, Omit { tablist: SignalLike; diff --git a/src/aria/accordion/accordion-panel.ts b/src/aria/accordion/accordion-panel.ts index ad4a0762c90e..56d52426f2a7 100644 --- a/src/aria/accordion/accordion-panel.ts +++ b/src/aria/accordion/accordion-panel.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Directive, afterRenderEffect, computed, inject, input} from '@angular/core'; +import {Directive, ElementRef, afterRenderEffect, computed, inject, input} from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {DeferredContentAware, AccordionTriggerPattern} from '../private'; @@ -48,6 +48,12 @@ import {DeferredContentAware, AccordionTriggerPattern} from '../private'; }, }) export class AccordionPanel { + /** A reference to the trigger element. */ + private readonly _elementRef = inject(ElementRef); + + /** A reference to the trigger element. */ + readonly element = this._elementRef.nativeElement as HTMLElement; + /** The DeferredContentAware host directive. */ private readonly _deferredContentAware = inject(DeferredContentAware); From efeb8ffd5f4507bd87c0fe19f6193333c11f72ce Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 6 Apr 2026 16:40:50 -0400 Subject: [PATCH 3/6] test(aria/accordion): Add tests for shuffled items and clean up util and init methods --- src/aria/accordion/accordion.spec.ts | 338 ++++++++++++++++----------- 1 file changed, 206 insertions(+), 132 deletions(-) diff --git a/src/aria/accordion/accordion.spec.ts b/src/aria/accordion/accordion.spec.ts index 0cbfb1861965..d7f09e3a038d 100644 --- a/src/aria/accordion/accordion.spec.ts +++ b/src/aria/accordion/accordion.spec.ts @@ -1,4 +1,4 @@ -import {Component, DebugElement, signal, ChangeDetectionStrategy} from '@angular/core'; +import {ChangeDetectionStrategy, Component, signal} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {provideFakeDirectionality, runAccessibilityChecks} from '@angular/cdk/testing/private'; @@ -10,27 +10,28 @@ import {AccordionGroup} from './accordion-group'; describe('AccordionGroup', () => { let fixture: ComponentFixture; - let triggerDebugElements: DebugElement[]; - let panelDebugElements: DebugElement[]; + let testComponent: AccordionGroupExample; + let parentElement: HTMLElement; + let triggerElements: HTMLElement[]; let panelElements: HTMLElement[]; - const keydown = (target: HTMLElement, key: string) => { - target.dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, key})); + const click = (target: HTMLElement) => { + target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true})); fixture.detectChanges(); }; - const click = (target: HTMLElement) => { - target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true})); + const keydown = (key: string, target = parentElement) => { + target.dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, key})); 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'); + const spaceKey = (target: HTMLElement) => keydown(' ', target); + const enterKey = (target: HTMLElement) => keydown('Enter', target); + const downArrowKey = () => keydown('ArrowDown'); + const upArrowKey = () => keydown('ArrowUp'); + const homeKey = () => keydown('Home'); + const endKey = () => keydown('End'); interface SetupOptions { multiExpandable?: boolean; @@ -42,7 +43,8 @@ describe('AccordionGroup', () => { } function configureAccordionComponent(opts: SetupOptions = {}) { - const testComponent = fixture.componentInstance as AccordionGroupExample; + testComponent = fixture.componentInstance as AccordionGroupExample; + parentElement = fixture.nativeElement.querySelector('[ngAccordionGroup]') as HTMLElement; if (opts.multiExpandable !== undefined) { testComponent.multiExpandable.set(opts.multiExpandable); @@ -63,25 +65,33 @@ describe('AccordionGroup', () => { opts.expandedItemValues.forEach(value => testComponent.expandItem(value, true)); } + refreshFixture(); + } + + function refreshFixture() { fixture.detectChanges(); - defineTestVariables(fixture); + defineTestVariables(); } - function defineTestVariables(currentFixture: ComponentFixture) { - triggerDebugElements = currentFixture.debugElement.queryAll(By.directive(AccordionTrigger)); - panelDebugElements = currentFixture.debugElement.queryAll(By.directive(AccordionPanel)); + function defineTestVariables() { + const triggerDebugElements = fixture.debugElement.queryAll(By.directive(AccordionTrigger)); + const panelDebugElements = fixture.debugElement.queryAll(By.directive(AccordionPanel)); triggerElements = triggerDebugElements.map(el => el.nativeElement); panelElements = panelDebugElements.map(el => el.nativeElement); } - function isTriggerActive(target: HTMLElement): boolean { - return target.getAttribute('data-active') === 'true'; - } + const isTriggerActive = (index: number) => + triggerElements[index].getAttribute('data-active') === 'true'; + const isTriggerExpanded = (index: number) => + triggerElements[index].getAttribute('aria-expanded') === 'true'; - function isTriggerExpanded(target: HTMLElement): boolean { - return target.getAttribute('aria-expanded') === 'true'; - } + const getTriggerAttribute = (index: number, attribute: string) => + triggerElements[index].getAttribute(attribute); + const getTriggerText = (index: number) => triggerElements[index].textContent?.trim(); + + const getPanelAttribute = (index: number, attribute: string) => + panelElements[index].getAttribute(attribute); afterEach(async () => { await runAccessibilityChecks(fixture.nativeElement); @@ -102,36 +112,36 @@ describe('AccordionGroup', () => { }); 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'); + expect(getTriggerAttribute(0, 'role')).toBe('button'); + expect(getTriggerAttribute(1, 'role')).toBe('button'); + expect(getTriggerAttribute(2, 'role')).toBe('button'); }); it('should have aria-expanded="false" when collapsed', () => { configureAccordionComponent(); - expect(triggerElements[0].getAttribute('aria-expanded')).toBe('false'); - expect(triggerElements[1].getAttribute('aria-expanded')).toBe('false'); - expect(triggerElements[2].getAttribute('aria-expanded')).toBe('false'); + expect(getTriggerAttribute(0, 'aria-expanded')).toBe('false'); + expect(getTriggerAttribute(1, 'aria-expanded')).toBe('false'); + expect(getTriggerAttribute(2, '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); + expect(getTriggerAttribute(0, 'aria-controls')).toBe(panelElements[0].id); + expect(getTriggerAttribute(1, 'aria-controls')).toBe(panelElements[1].id); + expect(getTriggerAttribute(2, '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'); + expect(getTriggerAttribute(0, 'aria-disabled')).toBe('false'); + expect(getTriggerAttribute(1, 'aria-disabled')).toBe('false'); + expect(getTriggerAttribute(2, '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'); + expect(getTriggerAttribute(0, 'aria-disabled')).toBe('true'); + expect(getTriggerAttribute(1, 'aria-disabled')).toBe('false'); + expect(getTriggerAttribute(2, 'aria-disabled')).toBe('false'); }); }); @@ -141,22 +151,22 @@ describe('AccordionGroup', () => { }); 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'); + expect(getPanelAttribute(0, 'role')).toBe('region'); + expect(getPanelAttribute(1, 'role')).toBe('region'); + expect(getPanelAttribute(2, '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); + expect(getPanelAttribute(0, 'aria-labelledby')).toBe(getTriggerAttribute(0, 'id')); + expect(getPanelAttribute(1, 'aria-labelledby')).toBe(getTriggerAttribute(1, 'id')); + expect(getPanelAttribute(2, 'aria-labelledby')).toBe(getTriggerAttribute(2, 'id')); }); it('should have "inert" attribute when collapsed', () => { configureAccordionComponent(); - expect(panelElements[0].hasAttribute('inert')).toBeTrue(); - expect(panelElements[1].hasAttribute('inert')).toBeTrue(); - expect(panelElements[2].hasAttribute('inert')).toBeTrue(); + expect(getPanelAttribute(0, 'inert')).toBe('true'); + expect(getPanelAttribute(1, 'inert')).toBe('true'); + expect(getPanelAttribute(2, 'inert')).toBe('true'); }); }); }); @@ -169,33 +179,33 @@ describe('AccordionGroup', () => { it('should expand panel on trigger click and update expanded panels', () => { click(triggerElements[0]); - expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); - expect(panelElements[0].hasAttribute('inert')).toBeFalse(); + expect(isTriggerExpanded(0)).toBeTrue(); + expect(panelElements[0].getAttribute('inert')).toBe(null); }); it('should collapes panel on trigger click and update expanded panels', () => { click(triggerElements[0]); click(triggerElements[0]); // Collapse - expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); - expect(panelElements[0].hasAttribute('inert')).toBeTrue(); + expect(isTriggerExpanded(0)).toBeFalse(); + expect(panelElements[0].getAttribute('inert')).toBe('true'); }); it('should expand one and collapse others', () => { click(triggerElements[0]); - expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + expect(isTriggerExpanded(0)).toBeTrue(); 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(isTriggerExpanded(0)).toBeFalse(); + expect(panelElements[0].getAttribute('inert')).toBe('true'); + expect(isTriggerExpanded(1)).toBeTrue(); + expect(panelElements[1].getAttribute('inert')).toBe(null); }); it('should allow setting initial value', () => { configureAccordionComponent({expandedItemValues: ['item-2'], multiExpandable: false}); - expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); - expect(isTriggerExpanded(triggerElements[1])).toBeTrue(); - expect(isTriggerExpanded(triggerElements[2])).toBeFalse(); + expect(isTriggerExpanded(0)).toBeFalse(); + expect(isTriggerExpanded(1)).toBeTrue(); + expect(isTriggerExpanded(2)).toBeFalse(); }); }); @@ -206,22 +216,22 @@ describe('AccordionGroup', () => { it('should expand multiple panels', () => { click(triggerElements[0]); - expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + expect(isTriggerExpanded(0)).toBeTrue(); click(triggerElements[1]); - expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); - expect(isTriggerExpanded(triggerElements[1])).toBeTrue(); + expect(isTriggerExpanded(0)).toBeTrue(); + expect(isTriggerExpanded(1)).toBeTrue(); }); it('should collapse an item without affecting others', () => { click(triggerElements[0]); click(triggerElements[1]); - expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); - expect(isTriggerExpanded(triggerElements[1])).toBeTrue(); + expect(isTriggerExpanded(0)).toBeTrue(); + expect(isTriggerExpanded(1)).toBeTrue(); click(triggerElements[0]); - expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); - expect(isTriggerExpanded(triggerElements[1])).toBeTrue(); + expect(isTriggerExpanded(0)).toBeFalse(); + expect(isTriggerExpanded(1)).toBeTrue(); }); it('should allow setting initial multiple values', () => { @@ -229,9 +239,9 @@ describe('AccordionGroup', () => { expandedItemValues: ['item-1', 'item-3'], multiExpandable: true, }); - expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); - expect(isTriggerExpanded(triggerElements[1])).toBeFalse(); - expect(isTriggerExpanded(triggerElements[2])).toBeTrue(); + expect(isTriggerExpanded(0)).toBeTrue(); + expect(isTriggerExpanded(1)).toBeFalse(); + expect(isTriggerExpanded(2)).toBeTrue(); }); }); @@ -239,16 +249,16 @@ describe('AccordionGroup', () => { it('should not expand a disabled trigger', () => { configureAccordionComponent({disabledItemValues: ['item-1']}); click(triggerElements[0]); - expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(isTriggerExpanded(0)).toBeFalse(); 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(isTriggerExpanded(0)).toBeFalse(); click(triggerElements[1]); - expect(isTriggerExpanded(triggerElements[1])).toBeFalse(); + expect(isTriggerExpanded(1)).toBeFalse(); }); }); }); @@ -260,81 +270,145 @@ describe('AccordionGroup', () => { // Focus on the first trigger as initial state. triggerElements[0].focus(); fixture.detectChanges(); - expect(isTriggerActive(triggerElements[0])).toBeTrue(); + expect(isTriggerActive(0)).toBeTrue(); }); it('should focus next trigger with ArrowDown', () => { - downArrowKey(triggerElements[0]); - expect(isTriggerActive(triggerElements[0])).toBeFalse(); - expect(isTriggerActive(triggerElements[1])).toBeTrue(); + downArrowKey(); + expect(isTriggerActive(0)).toBeFalse(); + expect(isTriggerActive(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(); + downArrowKey(); + expect(isTriggerActive(1)).toBeTrue(); + upArrowKey(); + expect(isTriggerActive(1)).toBeFalse(); + expect(isTriggerActive(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(); + downArrowKey(); + downArrowKey(); + expect(isTriggerActive(2)).toBeTrue(); + homeKey(); + expect(isTriggerActive(0)).toBeTrue(); }); it('should focus last trigger with End', () => { - endKey(triggerElements[0]); - expect(isTriggerActive(triggerElements[2])).toBeTrue(); + endKey(); + expect(isTriggerActive(2)).toBeTrue(); }); it('should toggle expansion of focused trigger with Enter', () => { - expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(isTriggerExpanded(0)).toBeFalse(); enterKey(triggerElements[0]); - expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + expect(isTriggerExpanded(0)).toBeTrue(); enterKey(triggerElements[0]); - expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(isTriggerExpanded(0)).toBeFalse(); }); it('should toggle expansion of focused trigger with Space', () => { - expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(isTriggerExpanded(0)).toBeFalse(); spaceKey(triggerElements[0]); - expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + expect(isTriggerExpanded(0)).toBeTrue(); spaceKey(triggerElements[0]); - expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(isTriggerExpanded(0)).toBeFalse(); + }); + + describe('with shuffled items', () => { + it('should focus on new last trigger with End', () => { + const items = testComponent.items().reverse(); + testComponent.items.set([...items]); + fixture.detectChanges(); + + // Now reversed, End should move to the former first trigger. + endKey(); + expect(isTriggerActive(0)).toBeTrue(); + }); + + it('should focus on newly prepended trigger with Begin', () => { + const items = testComponent.items(); + items.unshift({ + panelId: 'item-0', + header: 'Item 0 Header', + content: 'Item 0 Content', + disabled: signal(false), + expanded: signal(false), + }); + testComponent.items.set([...items]); + refreshFixture(); + + homeKey(); + expect(isTriggerActive(0)).toBeTrue(); + expect(getTriggerText(0)).toBe('Item 0 Header'); + }); + + it('should focus on newly appended trigger with End', () => { + const items = testComponent.items(); + items.push({ + panelId: 'item-4', + header: 'Item 4 Header', + content: 'Item 4 Content', + disabled: signal(false), + expanded: signal(false), + }); + testComponent.items.set([...items]); + refreshFixture(); + + endKey(); + expect(isTriggerActive(3)).toBeTrue(); + expect(getTriggerText(3)).toBe('Item 4 Header'); + }); + + it('should focus on inserted trigger with navigation', () => { + const items = testComponent.items(); + items.splice(2, 0, { + panelId: 'item-2a', + header: 'Item 2a Header', + content: 'Item 2a Content', + disabled: signal(false), + expanded: signal(false), + }); + testComponent.items.set([...items]); + refreshFixture(); + + downArrowKey(); + downArrowKey(); + expect(isTriggerActive(2)).toBeTrue(); + expect(triggerElements[2].textContent?.trim()).toBe('Item 2a Header'); + }); }); 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(); + endKey(); + expect(isTriggerActive(2)).toBeTrue(); + downArrowKey(); + expect(isTriggerActive(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(); + endKey(); + expect(isTriggerActive(2)).toBeTrue(); + downArrowKey(); + expect(isTriggerActive(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(); + expect(isTriggerActive(0)).toBeTrue(); + upArrowKey(); + expect(isTriggerActive(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(); + expect(isTriggerActive(0)).toBeTrue(); + upArrowKey(); + expect(isTriggerActive(0)).toBeTrue(); }); }); @@ -342,34 +416,34 @@ describe('AccordionGroup', () => { it('should skip disabled items if softDisabled=false', () => { configureAccordionComponent({softDisabled: false, disabledItemValues: ['item-2']}); - expect(isTriggerActive(triggerElements[0])).toBeTrue(); - downArrowKey(triggerElements[0]); - expect(isTriggerActive(triggerElements[2])).toBeTrue(); + expect(isTriggerActive(0)).toBeTrue(); + downArrowKey(); + expect(isTriggerActive(2)).toBeTrue(); }); it('should focus disabled items if softDisabled=true', () => { configureAccordionComponent({softDisabled: true, disabledItemValues: ['item-2']}); - expect(isTriggerActive(triggerElements[0])).toBeTrue(); - downArrowKey(triggerElements[0]); - expect(isTriggerActive(triggerElements[1])).toBeTrue(); + expect(isTriggerActive(0)).toBeTrue(); + downArrowKey(); + expect(isTriggerActive(1)).toBeTrue(); enterKey(triggerElements[1]); - expect(isTriggerExpanded(triggerElements[1])).toBeFalse(); + expect(isTriggerExpanded(1)).toBeFalse(); }); }); it('should not allow keyboard navigation if group is disabled', () => { configureAccordionComponent({disabledGroup: true}); - downArrowKey(triggerElements[0]); - expect(isTriggerActive(triggerElements[1])).toBeFalse(); + downArrowKey(); + expect(isTriggerActive(1)).toBeFalse(); }); it('should not allow expansion if group is disabled', () => { configureAccordionComponent({disabledGroup: true}); enterKey(triggerElements[0]); - expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); + expect(isTriggerExpanded(0)).toBeFalse(); }); }); }); @@ -388,7 +462,7 @@ describe('AccordionGroup', () => {
- items.map(item => (item.panelId === itemValue ? {...item, disabled} : item)), - ); + this.items() + .find(item => item.panelId === itemValue) + ?.disabled.set(disabled); } expandItem(itemValue: string, expanded: boolean) { - this.items.update(items => - items.map(item => (item.panelId === itemValue ? {...item, expanded} : item)), - ); + this.items() + .find(item => item.panelId === itemValue) + ?.expanded.set(expanded); } } From 9c721ed3cf4d5c11f64996111aae545a44ee7251 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 6 Apr 2026 17:13:04 -0400 Subject: [PATCH 4/6] refactor(aria/accordion): Fix toggle icons in example --- .../accordion-configurable-example.html | 92 +++++-------------- .../accordion-configurable-example.ts | 42 ++++++++- .../accordion-disabled-focusable-example.ts | 2 +- .../accordion-disabled-skipped-example.ts | 2 +- .../accordion-disabled-example.ts | 2 +- .../accordion-multi-expansion-example.ts | 2 +- .../accordion-single-expansion-example.ts | 2 +- 7 files changed, 64 insertions(+), 80 deletions(-) diff --git a/src/components-examples/aria/accordion/accordion-configurable/accordion-configurable-example.html b/src/components-examples/aria/accordion/accordion-configurable/accordion-configurable-example.html index 19c3e49bc15f..043475d7a2d3 100644 --- a/src/components-examples/aria/accordion/accordion-configurable/accordion-configurable-example.html +++ b/src/components-examples/aria/accordion/accordion-configurable/accordion-configurable-example.html @@ -13,75 +13,25 @@ [softDisabled]="softDisabled.value" [wrap]="wrap.value" > -
-

- -

-
- -

This is the content for Item 1.

- -
-
-
- -
-

- -

-
- -

This is the content for Item 2.

- -
-
-
- -
-

- -

-
- -

This is the content for Item 3.

-
-
-
- -
-

- -

-
- -

This is the content for Item 4

-
-
-
- -
-

- -

-
- -

This is the content for Item 5

-
-
-
+ @for (item of items(); track item.panelId) { +
+

+ +

+
+ +

{{item.content}}

+ +
+
+
+ }
diff --git a/src/components-examples/aria/accordion/accordion-configurable/accordion-configurable-example.ts b/src/components-examples/aria/accordion/accordion-configurable/accordion-configurable-example.ts index ce9e2f7a28f7..d0c2370bd394 100644 --- a/src/components-examples/aria/accordion/accordion-configurable/accordion-configurable-example.ts +++ b/src/components-examples/aria/accordion/accordion-configurable/accordion-configurable-example.ts @@ -1,4 +1,4 @@ -import {Component} from '@angular/core'; +import {Component, signal} from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {MatCheckboxModule} from '@angular/material/checkbox'; import {MatFormFieldModule} from '@angular/material/form-field'; @@ -37,7 +37,41 @@ export class AccordionConfigurableExample { disabled = new FormControl(false, {nonNullable: true}); softDisabled = new FormControl(true, {nonNullable: true}); - expansionIcon(panel: AccordionPanel): string { - return panel ? 'expand_less' : 'expand_more'; - } + items = signal([ + { + panelId: 'item-1', + header: 'Item 1 Trigger', + content: 'This is the content for Item 1.', + disabled: signal(false), + expanded: signal(false), + }, + { + panelId: 'item-2', + header: 'Item 2 Trigger (disabled)', + content: 'This is the content for Item 2.', + disabled: signal(true), + expanded: signal(false), + }, + { + panelId: 'item-3', + header: 'Item 3 Trigger', + content: 'This is the content for Item 3.', + disabled: signal(false), + expanded: signal(false), + }, + { + panelId: 'item-4', + header: 'Item 4 Trigger', + content: 'This is the content for Item 4.', + disabled: signal(false), + expanded: signal(false), + }, + { + panelId: 'item-5', + header: 'Item 5 Trigger', + content: 'This is the content for Item 5.', + disabled: signal(false), + expanded: signal(false), + }, + ]); } diff --git a/src/components-examples/aria/accordion/accordion-disabled-focusable/accordion-disabled-focusable-example.ts b/src/components-examples/aria/accordion/accordion-disabled-focusable/accordion-disabled-focusable-example.ts index cc98f4b90df4..17cdb83c7b0f 100644 --- a/src/components-examples/aria/accordion/accordion-disabled-focusable/accordion-disabled-focusable-example.ts +++ b/src/components-examples/aria/accordion/accordion-disabled-focusable/accordion-disabled-focusable-example.ts @@ -16,6 +16,6 @@ import { }) export class AccordionDisabledFocusableExample { expansionIcon(panel: AccordionPanel): string { - return panel ? 'expand_less' : 'expand_more'; + return panel.visible() ? 'expand_less' : 'expand_more'; } } diff --git a/src/components-examples/aria/accordion/accordion-disabled-skipped/accordion-disabled-skipped-example.ts b/src/components-examples/aria/accordion/accordion-disabled-skipped/accordion-disabled-skipped-example.ts index 0dbce2e86b28..976d32e46b4a 100644 --- a/src/components-examples/aria/accordion/accordion-disabled-skipped/accordion-disabled-skipped-example.ts +++ b/src/components-examples/aria/accordion/accordion-disabled-skipped/accordion-disabled-skipped-example.ts @@ -16,6 +16,6 @@ import { }) export class AccordionDisabledSkippedExample { expansionIcon(panel: AccordionPanel): string { - return panel ? 'expand_less' : 'expand_more'; + return panel.visible() ? 'expand_less' : 'expand_more'; } } diff --git a/src/components-examples/aria/accordion/accordion-disabled/accordion-disabled-example.ts b/src/components-examples/aria/accordion/accordion-disabled/accordion-disabled-example.ts index 5d5993367fd7..3862a1fe110f 100644 --- a/src/components-examples/aria/accordion/accordion-disabled/accordion-disabled-example.ts +++ b/src/components-examples/aria/accordion/accordion-disabled/accordion-disabled-example.ts @@ -16,6 +16,6 @@ import { }) export class AccordionDisabledExample { expansionIcon(panel: AccordionPanel): string { - return panel ? 'expand_less' : 'expand_more'; + return panel.visible() ? 'expand_less' : 'expand_more'; } } diff --git a/src/components-examples/aria/accordion/accordion-multi-expansion/accordion-multi-expansion-example.ts b/src/components-examples/aria/accordion/accordion-multi-expansion/accordion-multi-expansion-example.ts index 2eeb5166e014..24e49f64d5de 100644 --- a/src/components-examples/aria/accordion/accordion-multi-expansion/accordion-multi-expansion-example.ts +++ b/src/components-examples/aria/accordion/accordion-multi-expansion/accordion-multi-expansion-example.ts @@ -16,6 +16,6 @@ import { }) export class AccordionMultiExpansionExample { expansionIcon(panel: AccordionPanel): string { - return panel ? 'expand_less' : 'expand_more'; + return panel.visible() ? 'expand_less' : 'expand_more'; } } diff --git a/src/components-examples/aria/accordion/accordion-single-expansion/accordion-single-expansion-example.ts b/src/components-examples/aria/accordion/accordion-single-expansion/accordion-single-expansion-example.ts index 865ed34b61f1..17cb542ec90d 100644 --- a/src/components-examples/aria/accordion/accordion-single-expansion/accordion-single-expansion-example.ts +++ b/src/components-examples/aria/accordion/accordion-single-expansion/accordion-single-expansion-example.ts @@ -16,6 +16,6 @@ import { }) export class AccordionSingleExpansionExample { expansionIcon(panel: AccordionPanel): string { - return panel ? 'expand_less' : 'expand_more'; + return panel.visible() ? 'expand_less' : 'expand_more'; } } From 07f7d82d58e8071325444841bb55c9e1506eace4 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 6 Apr 2026 17:35:08 -0400 Subject: [PATCH 5/6] refactor(aria/accordion): Replace ContentChildren with manual registration of triggers --- goldens/aria/accordion/index.api.md | 11 +++++-- src/aria/accordion/accordion-group.ts | 32 ++++++++++++++++--- src/aria/accordion/accordion-trigger.ts | 16 ++++++++-- src/aria/accordion/accordion.spec.ts | 1 + .../accordion-configurable-example.html | 1 + 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/goldens/aria/accordion/index.api.md b/goldens/aria/accordion/index.api.md index 011f31ca0590..3378a901b755 100644 --- a/goldens/aria/accordion/index.api.md +++ b/goldens/aria/accordion/index.api.md @@ -25,11 +25,13 @@ export class AccordionGroup { expandAll(): void; readonly multiExpandable: _angular_core.InputSignalWithTransform; readonly _pattern: AccordionGroupPattern; + _registerTrigger(trigger: AccordionTrigger): void; readonly softDisabled: _angular_core.InputSignalWithTransform; readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>; + _unregisterTrigger(trigger: AccordionTrigger): void; readonly wrap: _angular_core.InputSignalWithTransform; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } @@ -51,7 +53,7 @@ export class AccordionPanel { } // @public -export class AccordionTrigger implements OnInit { +export class AccordionTrigger implements OnInit, OnDestroy { readonly active: _angular_core.Signal; collapse(): void; readonly disabled: _angular_core.InputSignalWithTransform; @@ -59,6 +61,9 @@ export class AccordionTrigger implements OnInit { expand(): void; readonly expanded: _angular_core.ModelSignal; readonly id: _angular_core.InputSignal; + readonly index: _angular_core.InputSignal; + // (undocumented) + ngOnDestroy(): void; // (undocumented) ngOnInit(): void; readonly panel: _angular_core.InputSignal; @@ -66,7 +71,7 @@ export class AccordionTrigger implements OnInit { _pattern: AccordionTriggerPattern; toggle(): void; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/src/aria/accordion/accordion-group.ts b/src/aria/accordion/accordion-group.ts index 929ae7aea49a..f6d1a013cb65 100644 --- a/src/aria/accordion/accordion-group.ts +++ b/src/aria/accordion/accordion-group.ts @@ -11,13 +11,12 @@ import { ElementRef, booleanAttribute, computed, - contentChildren, inject, input, signal, } from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; -import {AccordionGroupPattern} from '../private'; +import {AccordionGroupPattern, sortDirectives} from '../private'; import {AccordionTrigger} from './accordion-trigger'; import {ACCORDION_GROUP} from './accordion-tokens'; @@ -75,10 +74,23 @@ export class AccordionGroup { readonly element = this._elementRef.nativeElement as HTMLElement; /** The AccordionTriggers nested inside this group. */ - private readonly _triggers = contentChildren(AccordionTrigger, {descendants: true}); + private readonly _triggers = signal(new Set()); + + /** The AccordionTriggers nested inside this group. */ + private readonly _sortedTriggers = computed(() => { + const triggers = [...this._triggers()] as AccordionTrigger[]; + const sortFn = + triggers[0]?.index() === undefined + ? sortDirectives + : (a: AccordionTrigger, b: AccordionTrigger) => a.index()! - b.index()!; + + return triggers.sort(sortFn); + }); /** The corresponding patterns for the accordion triggers. */ - private readonly _triggerPatterns = computed(() => this._triggers().map(t => t._pattern)); + private readonly _triggerPatterns = computed(() => { + return this._sortedTriggers().map(t => t._pattern); + }); /** The text direction (ltr or rtl). */ readonly textDirection = inject(Directionality).valueSignal; @@ -117,4 +129,16 @@ export class AccordionGroup { collapseAll() { this._pattern.collapseAll(); } + + /** Internal method to register each trigger as we can not use contentChildren. */ + _registerTrigger(trigger: AccordionTrigger) { + this._triggers().add(trigger); + this._triggers.set(new Set(this._triggers())); + } + + /** Internal method to unregister each trigger as we can not use contentChildren. */ + _unregisterTrigger(trigger: AccordionTrigger) { + this._triggers().delete(trigger); + this._triggers.set(new Set(this._triggers())); + } } diff --git a/src/aria/accordion/accordion-trigger.ts b/src/aria/accordion/accordion-trigger.ts index 238f3d8171ec..0d982bd4f1b7 100644 --- a/src/aria/accordion/accordion-trigger.ts +++ b/src/aria/accordion/accordion-trigger.ts @@ -9,6 +9,7 @@ import { Directive, ElementRef, + OnDestroy, OnInit, booleanAttribute, computed, @@ -53,7 +54,7 @@ import {AccordionPanel} from './accordion-panel'; '[attr.tabindex]': '_pattern.tabIndex()', }, }) -export class AccordionTrigger implements OnInit { +export class AccordionTrigger implements OnInit, OnDestroy { /** A reference to the trigger element. */ private readonly _elementRef = inject(ElementRef); @@ -69,12 +70,15 @@ export class AccordionTrigger implements OnInit { /** The unique identifier for the trigger. */ readonly id = input(inject(_IdGenerator).getId('ng-accordion-trigger-', true)); - /** The unique identifier for the correspondingtrigger panel. */ + /** The unique identifier for the corresponding trigger panel. */ readonly panelId = computed(() => this.panel().id()); /** Whether the trigger is disabled. */ readonly disabled = input(false, {transform: booleanAttribute}); + /** The index of the trigger within the accordion group. */ + readonly index = input(); + /** Whether the corresponding panel is expanded. */ readonly expanded = model(false); @@ -93,6 +97,14 @@ export class AccordionTrigger implements OnInit { }); this.panel()._pattern = this._pattern; + + this._accordionGroup._registerTrigger(this); + } + + ngOnDestroy() { + this.panel()._pattern = undefined; + + this._accordionGroup._unregisterTrigger(this); } /** Expands this item. */ diff --git a/src/aria/accordion/accordion.spec.ts b/src/aria/accordion/accordion.spec.ts index d7f09e3a038d..9f45998f0b28 100644 --- a/src/aria/accordion/accordion.spec.ts +++ b/src/aria/accordion/accordion.spec.ts @@ -461,6 +461,7 @@ describe('AccordionGroup', () => {
+
+ + Item 1 Content + +
+
+ } + @if (includeSecond()) { +
+ +
+ + Item 2 Content + +
+
+ } + @if (includeThird()) { +
+ +
+ + Item 3 Content + +
+
+ } + + `, + imports: [AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class AccordionGroupWithIfs extends AccordionGroupWithLoop { + includeFirst = signal(true); + includeSecond = signal(true); + includeThird = signal(true); +}