diff --git a/src/aria/accordion/accordion.spec.ts b/src/aria/accordion/accordion.spec.ts index f98361b2a251..5470a56d0305 100644 --- a/src/aria/accordion/accordion.spec.ts +++ b/src/aria/accordion/accordion.spec.ts @@ -1,4 +1,4 @@ -import {Component, DebugElement, signal, model} from '@angular/core'; +import {Component, DebugElement, 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'; @@ -7,10 +7,8 @@ import {AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent} from describe('AccordionGroup', () => { let fixture: ComponentFixture; - let groupDebugElement: DebugElement; let triggerDebugElements: DebugElement[]; let panelDebugElements: DebugElement[]; - let groupInstance: AccordionGroup; let triggerElements: HTMLElement[]; let panelElements: HTMLElement[]; @@ -32,9 +30,9 @@ describe('AccordionGroup', () => { const endKey = (target: HTMLElement) => keydown(target, 'End'); interface SetupOptions { - initialExpandedPanels?: string[]; multiExpandable?: boolean; disabledGroup?: boolean; + expandedItemValues?: string[]; disabledItemValues?: string[]; softDisabled?: boolean; wrap?: boolean; @@ -43,9 +41,6 @@ describe('AccordionGroup', () => { function configureAccordionComponent(opts: SetupOptions = {}) { const testComponent = fixture.componentInstance as AccordionGroupExample; - if (opts.initialExpandedPanels !== undefined) { - testComponent.expandedPanels.set(opts.initialExpandedPanels); - } if (opts.multiExpandable !== undefined) { testComponent.multiExpandable.set(opts.multiExpandable); } @@ -61,17 +56,18 @@ describe('AccordionGroup', () => { if (opts.disabledItemValues !== undefined) { opts.disabledItemValues.forEach(value => testComponent.disableItem(value, true)); } + if (opts.expandedItemValues !== undefined) { + opts.expandedItemValues.forEach(value => testComponent.expandItem(value, true)); + } fixture.detectChanges(); defineTestVariables(fixture); } function defineTestVariables(currentFixture: ComponentFixture) { - groupDebugElement = currentFixture.debugElement.query(By.directive(AccordionGroup)); triggerDebugElements = currentFixture.debugElement.queryAll(By.directive(AccordionTrigger)); panelDebugElements = currentFixture.debugElement.queryAll(By.directive(AccordionPanel)); - groupInstance = groupDebugElement.injector.get(AccordionGroup); triggerElements = triggerDebugElements.map(el => el.nativeElement); panelElements = panelDebugElements.map(el => el.nativeElement); } @@ -109,7 +105,7 @@ describe('AccordionGroup', () => { }); it('should have aria-expanded="false" when collapsed', () => { - configureAccordionComponent({initialExpandedPanels: []}); + 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'); @@ -154,7 +150,7 @@ describe('AccordionGroup', () => { }); it('should have "inert" attribute when collapsed', () => { - configureAccordionComponent({initialExpandedPanels: []}); + configureAccordionComponent(); expect(panelElements[0].hasAttribute('inert')).toBeTrue(); expect(panelElements[1].hasAttribute('inert')).toBeTrue(); expect(panelElements[2].hasAttribute('inert')).toBeTrue(); @@ -172,7 +168,6 @@ describe('AccordionGroup', () => { click(triggerElements[0]); expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); expect(panelElements[0].hasAttribute('inert')).toBeFalse(); - expect(groupInstance.expandedPanels()).toEqual(['item-1']); }); it('should collapes panel on trigger click and update expanded panels', () => { @@ -180,24 +175,21 @@ describe('AccordionGroup', () => { click(triggerElements[0]); // Collapse expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); expect(panelElements[0].hasAttribute('inert')).toBeTrue(); - expect(groupInstance.expandedPanels()).toEqual([]); }); it('should expand one and collapse others', () => { click(triggerElements[0]); expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); - expect(groupInstance.expandedPanels()).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.expandedPanels()).toEqual(['item-2']); }); it('should allow setting initial value', () => { - configureAccordionComponent({initialExpandedPanels: ['item-2'], multiExpandable: false}); + configureAccordionComponent({expandedItemValues: ['item-2'], multiExpandable: false}); expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); expect(isTriggerExpanded(triggerElements[1])).toBeTrue(); expect(isTriggerExpanded(triggerElements[2])).toBeFalse(); @@ -221,19 +213,17 @@ describe('AccordionGroup', () => { it('should collapse an item without affecting others', () => { click(triggerElements[0]); click(triggerElements[1]); - expect(groupInstance.expandedPanels()).toEqual( - jasmine.arrayWithExactContents(['item-1', 'item-2']), - ); + expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); + expect(isTriggerExpanded(triggerElements[1])).toBeTrue(); click(triggerElements[0]); expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); expect(isTriggerExpanded(triggerElements[1])).toBeTrue(); - expect(groupInstance.expandedPanels()).toEqual(['item-2']); }); it('should allow setting initial multiple values', () => { configureAccordionComponent({ - initialExpandedPanels: ['item-1', 'item-3'], + expandedItemValues: ['item-1', 'item-3'], multiExpandable: true, }); expect(isTriggerExpanded(triggerElements[0])).toBeTrue(); @@ -247,7 +237,6 @@ describe('AccordionGroup', () => { configureAccordionComponent({disabledItemValues: ['item-1']}); click(triggerElements[0]); expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); - expect(groupInstance.expandedPanels()).toEqual([]); expect(triggerElements[0].getAttribute('aria-disabled')).toBe('true'); }); @@ -255,7 +244,6 @@ describe('AccordionGroup', () => { configureAccordionComponent({disabledGroup: true}); click(triggerElements[0]); expect(isTriggerExpanded(triggerElements[0])).toBeFalse(); - expect(groupInstance.expandedPanels()).toEqual([]); click(triggerElements[1]); expect(isTriggerExpanded(triggerElements[1])).toBeFalse(); }); @@ -387,7 +375,6 @@ describe('AccordionGroup', () => { template: `
{ ngAccordionTrigger [panelId]="item.panelId" [disabled]="item.disabled" + [(expanded)]="item.expanded" >{{ item.header }}
{ }) class AccordionGroupExample { items = signal([ - {panelId: 'item-1', header: 'Item 1 Header', content: 'Item 1 Content', disabled: false}, - {panelId: 'item-2', header: 'Item 2 Header', content: 'Item 2 Content', disabled: false}, - {panelId: 'item-3', header: 'Item 3 Header', content: 'Item 3 Content', disabled: false}, + { + panelId: 'item-1', + header: 'Item 1 Header', + content: 'Item 1 Content', + disabled: false, + expanded: false, + }, + { + panelId: 'item-2', + header: 'Item 2 Header', + content: 'Item 2 Content', + disabled: false, + expanded: false, + }, + { + panelId: 'item-3', + header: 'Item 3 Header', + content: 'Item 3 Content', + disabled: false, + expanded: false, + }, ]); - expandedPanels = model([]); multiExpandable = signal(false); disabledGroup = signal(false); softDisabled = signal(true); @@ -432,4 +437,10 @@ class AccordionGroupExample { items.map(item => (item.panelId === itemValue ? {...item, disabled} : item)), ); } + + expandItem(itemValue: string, expanded: boolean) { + this.items.update(items => + items.map(item => (item.panelId === itemValue ? {...item, expanded} : item)), + ); + } } diff --git a/src/aria/accordion/accordion.ts b/src/aria/accordion/accordion.ts index ede5338e6505..3249e05bee9d 100644 --- a/src/aria/accordion/accordion.ts +++ b/src/aria/accordion/accordion.ts @@ -74,7 +74,7 @@ export class AccordionPanel { private readonly _id = inject(_IdGenerator).getId('accordion-trigger-', true); /** A local unique identifier for the panel, used to match with its trigger's `panelId`. */ - panelId = input.required(); + readonly panelId = input.required(); /** Whether the accordion panel is visible. True if the associated trigger is expanded. */ readonly visible = computed(() => !this._pattern.hidden()); @@ -99,17 +99,17 @@ export class AccordionPanel { /** Expands this item. */ expand() { - this.accordionTrigger()?.expansionControl.open(); + this.accordionTrigger()?.open(); } /** Collapses this item. */ collapse() { - this.accordionTrigger()?.expansionControl.close(); + this.accordionTrigger()?.close(); } /** Toggles the expansion state of this item. */ toggle() { - this.accordionTrigger()?.expansionControl.toggle(); + this.accordionTrigger()?.toggle(); } } @@ -141,65 +141,58 @@ export class AccordionPanel { '[attr.aria-expanded]': 'expanded()', '[attr.aria-controls]': '_pattern.controls()', '[attr.aria-disabled]': '_pattern.disabled()', - '[attr.disabled]': 'hardDisabled() ? true : null', + '[attr.disabled]': '_pattern.hardDisabled() ? true : null', '[attr.tabindex]': '_pattern.tabIndex()', - '(keydown)': '_pattern.onKeydown($event)', - '(pointerdown)': '_pattern.onPointerdown($event)', - '(focusin)': '_pattern.onFocus($event)', }, }) export class AccordionTrigger { - /** A global unique identifier for the trigger. */ - private readonly _id = inject(_IdGenerator).getId('ng-accordion-trigger-', true); - /** A reference to the trigger element. */ private readonly _elementRef = inject(ElementRef); /** The parent AccordionGroup. */ private readonly _accordionGroup = inject(AccordionGroup); + /** A unique identifier for the widget. */ + readonly id = input(inject(_IdGenerator).getId('ng-accordion-trigger-', true)); + + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); + /** A local unique identifier for the trigger, used to match with its panel's `panelId`. */ - panelId = input.required(); + readonly panelId = input.required(); /** Whether the trigger is disabled. */ - disabled = input(false, {transform: booleanAttribute}); + readonly disabled = input(false, {transform: booleanAttribute}); + + /** Whether the corresponding panel is expanded. */ + readonly expanded = model(false); /** Whether the trigger is active. */ readonly active = computed(() => this._pattern.active()); - /** Whether the trigger is expanded. */ - readonly expanded = computed(() => this._pattern.expanded()); - - // TODO(ok7sai): Consider moving this to UI patterns. - /** Whether this trigger is inaccessible via keyboard navigation. */ - readonly hardDisabled = computed(() => this._pattern.disabled() && this._pattern.tabIndex() < 0); - /** The accordion panel pattern controlled by this trigger. This is set by AccordionGroup. */ - readonly accordionPanel: WritableSignal = signal(undefined); + readonly _accordionPanel: WritableSignal = signal(undefined); /** The UI pattern instance for this trigger. */ readonly _pattern: AccordionTriggerPattern = new AccordionTriggerPattern({ - id: () => this._id, - panelId: this.panelId, - disabled: this.disabled, - element: () => this._elementRef.nativeElement, + ...this, accordionGroup: computed(() => this._accordionGroup._pattern), - accordionPanel: this.accordionPanel, + accordionPanel: this._accordionPanel, }); /** Expands this item. */ expand() { - this._pattern.expansionControl.open(); + this._pattern.open(); } /** Collapses this item. */ collapse() { - this._pattern.expansionControl.close(); + this._pattern.close(); } /** Toggles the expansion state of this item. */ toggle() { - this._pattern.expansionControl.toggle(); + this._pattern.toggle(); } } @@ -243,6 +236,9 @@ export class AccordionTrigger { exportAs: 'ngAccordionGroup', host: { 'class': 'ng-accordion-group', + '(keydown)': '_pattern.onKeydown($event)', + '(pointerdown)': '_pattern.onPointerdown($event)', + '(focusin)': '_pattern.onFocus($event)', }, }) export class AccordionGroup { @@ -250,43 +246,43 @@ export class AccordionGroup { private readonly _elementRef = inject(ElementRef); /** The AccordionTriggers nested inside this group. */ - protected readonly _triggers = contentChildren(AccordionTrigger, {descendants: true}); + private readonly _triggers = contentChildren(AccordionTrigger, {descendants: true}); + + /** The AccordionTrigger patterns nested inside this group. */ + private readonly _triggerPatterns = computed(() => this._triggers().map(t => t._pattern)); /** The AccordionPanels nested inside this group. */ - protected readonly _panels = contentChildren(AccordionPanel, {descendants: true}); + private readonly _panels = contentChildren(AccordionPanel, {descendants: true}); + + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); /** The text direction (ltr or rtl). */ readonly textDirection = inject(Directionality).valueSignal; /** Whether the entire accordion group is disabled. */ - disabled = input(false, {transform: booleanAttribute}); + readonly disabled = input(false, {transform: booleanAttribute}); /** Whether multiple accordion items can be expanded simultaneously. */ - multiExpandable = input(true, {transform: booleanAttribute}); - - /** The ids of the currently expanded accordion panels. */ - expandedPanels = model([]); + readonly multiExpandable = input(true, {transform: booleanAttribute}); /** * Whether to allow disabled items to receive focus. When `true`, disabled items are * focusable but not interactive. When `false`, disabled items are skipped during navigation. */ - softDisabled = input(true, {transform: booleanAttribute}); + readonly softDisabled = 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}); + readonly wrap = input(false, {transform: booleanAttribute}); /** The UI pattern instance for this accordion group. */ readonly _pattern: AccordionGroupPattern = new AccordionGroupPattern({ ...this, - // TODO(ok7sai): Consider making `activeItem` an internal state in the pattern and call - // `setDefaultState` in the CDK. activeItem: signal(undefined), - items: computed(() => this._triggers().map(trigger => trigger._pattern)), - expandedIds: this.expandedPanels, + items: this._triggerPatterns, // TODO(ok7sai): Investigate whether an accordion should support horizontal mode. orientation: () => 'vertical', - element: () => this._elementRef.nativeElement, + getItem: e => this._getItem(e), }); constructor() { @@ -297,7 +293,7 @@ export class AccordionGroup { for (const trigger of triggers) { const panel = panels.find(p => p.panelId() === trigger.panelId()); - trigger.accordionPanel.set(panel?._pattern); + trigger._accordionPanel.set(panel?._pattern); if (panel) { panel.accordionTrigger.set(trigger._pattern); } @@ -307,12 +303,28 @@ export class AccordionGroup { /** Expands all accordion panels if multi-expandable. */ expandAll() { - this._pattern.expansionManager.openAll(); + this._pattern.expansionBehavior.openAll(); } /** Collapses all accordion panels. */ collapseAll() { - this._pattern.expansionManager.closeAll(); + this._pattern.expansionBehavior.closeAll(); + } + + /** Gets the trigger pattern for a given element. */ + private _getItem(element: Element | null | undefined): AccordionTriggerPattern | undefined { + let target = element; + + while (target) { + const pattern = this._triggerPatterns().find(t => t.element() === target); + if (pattern) { + return pattern; + } + + target = target.parentElement?.closest('[ngAccordionTrigger]'); + } + + return undefined; } } diff --git a/src/aria/private/accordion/accordion.spec.ts b/src/aria/private/accordion/accordion.spec.ts index 37bf6e1b71e0..567790ef52a2 100644 --- a/src/aria/private/accordion/accordion.spec.ts +++ b/src/aria/private/accordion/accordion.spec.ts @@ -65,10 +65,10 @@ describe('Accordion Pattern', () => { disabled: signal(false), multiExpandable: signal(true), items: signal([]), - expandedIds: signal([]), softDisabled: signal(true), wrap: signal(true), element: signal(document.createElement('div')), + getItem: e => triggerPatterns.find(i => i.element() === e), }; groupPattern = new AccordionGroupPattern(groupInputs); @@ -81,6 +81,7 @@ describe('Accordion Pattern', () => { element: signal(createAccordionTriggerElement()), disabled: signal(false), panelId: signal('panel-1'), // Value should match the panel it controls + expanded: signal(false), }, { accordionGroup: signal(groupPattern), @@ -89,6 +90,7 @@ describe('Accordion Pattern', () => { element: signal(createAccordionTriggerElement()), disabled: signal(false), panelId: signal('panel-2'), + expanded: signal(false), }, { accordionGroup: signal(groupPattern), @@ -97,6 +99,7 @@ describe('Accordion Pattern', () => { element: signal(createAccordionTriggerElement()), disabled: signal(false), panelId: signal('panel-3'), + expanded: signal(false), }, ]; triggerPatterns = [ @@ -141,12 +144,6 @@ describe('Accordion Pattern', () => { 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'); @@ -159,47 +156,49 @@ describe('Accordion Pattern', () => { describe('Keyboard Navigation', () => { it('does not handle keyboard event if an accordion group is disabled.', () => { groupInputs.disabled.set(true); - triggerPatterns[0].onKeydown(space()); + groupInputs.activeItem.set(triggerPatterns[0]); + groupPattern.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()); + groupInputs.activeItem.set(triggerPatterns[0]); + groupPattern.onKeydown(space()); expect(panelPatterns[0].hidden()).toBeTrue(); }); it('navigates to first accordion trigger with home key.', () => { - groupInputs.activeItem.set(groupInputs.items()[2]); + groupInputs.activeItem.set(triggerPatterns[2]); expect(triggerPatterns[2].active()).toBeTrue(); - triggerPatterns[2].onKeydown(home()); + groupPattern.onKeydown(home()); expect(triggerPatterns[2].active()).toBeFalse(); expect(triggerPatterns[0].active()).toBeTrue(); }); it('navigates to last accordion trigger with end key.', () => { - groupInputs.activeItem.set(groupInputs.items()[0]); + groupInputs.activeItem.set(triggerPatterns[0]); expect(triggerPatterns[0].active()).toBeTrue(); - triggerPatterns[0].onKeydown(end()); + groupPattern.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.activeItem.set(groupInputs.items()[0]); + groupInputs.activeItem.set(triggerPatterns[0]); expect(triggerPatterns[0].active()).toBeTrue(); expect(triggerPatterns[1].active()).toBeFalse(); - triggerPatterns[0].onKeydown(down()); + groupPattern.onKeydown(down()); expect(triggerPatterns[0].active()).toBeFalse(); expect(triggerPatterns[1].active()).toBeTrue(); }); it('navigates to the previous trigger with up key.', () => { - groupInputs.activeItem.set(groupInputs.items()[1]); + groupInputs.activeItem.set(triggerPatterns[1]); expect(triggerPatterns[0].active()).toBeFalse(); expect(triggerPatterns[1].active()).toBeTrue(); - triggerPatterns[1].onKeydown(up()); + groupPattern.onKeydown(up()); expect(triggerPatterns[1].active()).toBeFalse(); expect(triggerPatterns[0].active()).toBeTrue(); }); @@ -210,19 +209,19 @@ describe('Accordion Pattern', () => { }); it('navigates to the last trigger with up key from first trigger.', () => { - groupInputs.activeItem.set(groupInputs.items()[0]); + groupInputs.activeItem.set(triggerPatterns[0]); expect(triggerPatterns[0].active()).toBeTrue(); expect(triggerPatterns[2].active()).toBeFalse(); - triggerPatterns[0].onKeydown(up()); + groupPattern.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.activeItem.set(groupInputs.items()[2]); + groupInputs.activeItem.set(triggerPatterns[2]); expect(triggerPatterns[0].active()).toBeFalse(); expect(triggerPatterns[2].active()).toBeTrue(); - triggerPatterns[2].onKeydown(down()); + groupPattern.onKeydown(down()); expect(triggerPatterns[0].active()).toBeTrue(); expect(triggerPatterns[2].active()).toBeFalse(); }); @@ -234,16 +233,16 @@ describe('Accordion Pattern', () => { }); it('stays on the first trigger with up key from first trigger.', () => { - groupInputs.activeItem.set(groupInputs.items()[0]); + groupInputs.activeItem.set(triggerPatterns[0]); expect(triggerPatterns[0].active()).toBeTrue(); - triggerPatterns[0].onKeydown(up()); + groupPattern.onKeydown(up()); expect(triggerPatterns[0].active()).toBeTrue(); }); it('stays on the last trigger with down key from last trigger.', () => { - groupInputs.activeItem.set(groupInputs.items()[2]); + groupInputs.activeItem.set(triggerPatterns[2]); expect(triggerPatterns[2].active()).toBeTrue(); - triggerPatterns[2].onKeydown(down()); + groupPattern.onKeydown(down()); expect(triggerPatterns[2].active()).toBeTrue(); }); }); @@ -255,19 +254,19 @@ describe('Accordion Pattern', () => { }); it('navigates to the next trigger with right key.', () => { - groupInputs.activeItem.set(groupInputs.items()[0]); + groupInputs.activeItem.set(triggerPatterns[0]); expect(triggerPatterns[0].active()).toBeTrue(); expect(triggerPatterns[1].active()).toBeFalse(); - triggerPatterns[0].onKeydown(right()); + groupPattern.onKeydown(right()); expect(triggerPatterns[0].active()).toBeFalse(); expect(triggerPatterns[1].active()).toBeTrue(); }); it('navigates to the previous trigger with left key.', () => { - groupInputs.activeItem.set(groupInputs.items()[1]); + groupInputs.activeItem.set(triggerPatterns[1]); expect(triggerPatterns[0].active()).toBeFalse(); expect(triggerPatterns[1].active()).toBeTrue(); - triggerPatterns[1].onKeydown(left()); + groupPattern.onKeydown(left()); expect(triggerPatterns[1].active()).toBeFalse(); expect(triggerPatterns[0].active()).toBeTrue(); }); @@ -278,19 +277,19 @@ describe('Accordion Pattern', () => { }); it('navigates to the last trigger with left key from first trigger.', () => { - groupInputs.activeItem.set(groupInputs.items()[0]); + groupInputs.activeItem.set(triggerPatterns[0]); expect(triggerPatterns[0].active()).toBeTrue(); expect(triggerPatterns[2].active()).toBeFalse(); - triggerPatterns[0].onKeydown(left()); + groupPattern.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.activeItem.set(groupInputs.items()[2]); + groupInputs.activeItem.set(triggerPatterns[2]); expect(triggerPatterns[2].active()).toBeTrue(); expect(triggerPatterns[0].active()).toBeFalse(); - triggerPatterns[2].onKeydown(right()); + groupPattern.onKeydown(right()); expect(triggerPatterns[2].active()).toBeFalse(); expect(triggerPatterns[0].active()).toBeTrue(); }); @@ -302,16 +301,16 @@ describe('Accordion Pattern', () => { }); it('stays on the first trigger with left key from first trigger.', () => { - groupInputs.activeItem.set(groupInputs.items()[0]); + groupInputs.activeItem.set(triggerPatterns[0]); expect(triggerPatterns[0].active()).toBeTrue(); - triggerPatterns[0].onKeydown(left()); + groupPattern.onKeydown(left()); expect(triggerPatterns[0].active()).toBeTrue(); }); it('stays on the last trigger with right key from last trigger.', () => { - groupInputs.activeItem.set(groupInputs.items()[2]); + groupInputs.activeItem.set(triggerPatterns[2]); expect(triggerPatterns[2].active()).toBeTrue(); - triggerPatterns[2].onKeydown(right()); + groupPattern.onKeydown(right()); expect(triggerPatterns[2].active()).toBeTrue(); }); }); @@ -323,21 +322,21 @@ describe('Accordion Pattern', () => { }); it('expands a panel and collapses others with space key.', () => { - groupInputs.expandedIds.set(['panel-2']); + triggerPatterns[1].expanded.set(true); expect(panelPatterns[0].hidden()).toBeTrue(); expect(panelPatterns[1].hidden()).toBeFalse(); - triggerPatterns[0].onKeydown(space()); + groupPattern.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']); + triggerPatterns[1].expanded.set(true); expect(panelPatterns[0].hidden()).toBeTrue(); expect(panelPatterns[1].hidden()).toBeFalse(); - triggerPatterns[0].onKeydown(space()); + groupPattern.onKeydown(space()); expect(panelPatterns[0].hidden()).toBeFalse(); expect(panelPatterns[1].hidden()).toBeTrue(); }); @@ -349,21 +348,22 @@ describe('Accordion Pattern', () => { }); it('expands a panel without affecting other panels.', () => { - groupInputs.expandedIds.set(['panel-2']); + triggerPatterns[1].expanded.set(true); expect(panelPatterns[0].hidden()).toBeTrue(); expect(panelPatterns[1].hidden()).toBeFalse(); - triggerPatterns[0].onKeydown(space()); + groupPattern.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']); + triggerPatterns[0].expanded.set(true); + triggerPatterns[1].expanded.set(true); expect(panelPatterns[0].hidden()).toBeFalse(); expect(panelPatterns[1].hidden()).toBeFalse(); - triggerPatterns[0].onKeydown(enter()); + groupPattern.onKeydown(enter()); expect(panelPatterns[0].hidden()).toBeTrue(); expect(panelPatterns[1].hidden()).toBeFalse(); }); diff --git a/src/aria/private/accordion/accordion.ts b/src/aria/private/accordion/accordion.ts index f560b845c653..4cb551a85a3f 100644 --- a/src/aria/private/accordion/accordion.ts +++ b/src/aria/private/accordion/accordion.ts @@ -8,161 +8,90 @@ import {computed} from '@angular/core'; import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; -import { - ExpansionItem, - ExpansionControl, - ListExpansion, - ListExpansionInputs, -} from '../behaviors/expansion/expansion'; +import {ExpansionItem, 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'; +import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; /** Inputs of the AccordionGroupPattern. */ -export type AccordionGroupInputs = Omit< - ListNavigationInputs & - ListFocusInputs & - Omit, - 'focusMode' ->; +export interface AccordionGroupInputs + extends Omit< + ListNavigationInputs & + ListFocusInputs & + Omit, + 'focusMode' + > { + /** A function that returns the trigger associated with a given element. */ + getItem: (e: Element | null | undefined) => AccordionTriggerPattern | undefined; +} 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; + readonly navigationBehavior: ListNavigation; /** Controls focus for the group. */ - focusManager: ListFocus; + readonly focusBehavior: ListFocus; /** Controls expansion for the group. */ - expansionManager: ListExpansion; + readonly expansionBehavior: ListExpansion; constructor(readonly inputs: AccordionGroupInputs) { - this.wrap = inputs.wrap; - this.orientation = inputs.orientation; - this.textDirection = inputs.textDirection; - this.activeItem = inputs.activeItem; - this.disabled = inputs.disabled; - this.multiExpandable = inputs.multiExpandable; - this.items = inputs.items; - this.expandedIds = inputs.expandedIds; - this.softDisabled = inputs.softDisabled; - this.focusManager = new ListFocus({ + this.focusBehavior = new ListFocus({ ...inputs, focusMode, }); - this.navigation = new ListNavigation({ + this.navigationBehavior = new ListNavigation({ ...inputs, focusMode, - focusManager: this.focusManager, + focusManager: this.focusBehavior, }); - this.expansionManager = new ListExpansion({ + this.expansionBehavior = new ListExpansion({ ...inputs, }); } -} - -/** Inputs for the AccordionTriggerPattern. */ -export type AccordionTriggerInputs = Omit & - Omit & { - /** A local unique identifier for the trigger's corresponding panel. */ - panelId: 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().activeItem() === this); - - /** Id of the accordion panel controlled by the trigger. */ - controls = computed(() => this.inputs.accordionPanel()?.id()); - - /** The tab index 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()); - - /** The index of the trigger within its accordion group. */ - index = computed(() => this.inputs.accordionGroup().items().indexOf(this)); - - constructor(readonly inputs: AccordionTriggerInputs) { - this.id = inputs.id; - this.element = inputs.element; - this.panelId = inputs.panelId; - this.expansionControl = new ExpansionControl({ - ...inputs, - expansionId: inputs.panelId, - 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') { + if (this.inputs.orientation() === 'vertical') { return 'ArrowUp'; } - return this.inputs.accordionGroup().textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; + return this.inputs.textDirection() === 'rtl' ? 'ArrowRight' : 'ArrowLeft'; }); /** The key used to navigate to the next accordion trigger. */ nextKey = computed(() => { - if (this.inputs.accordionGroup().orientation() === 'vertical') { + if (this.inputs.orientation() === 'vertical') { return 'ArrowDown'; } - return this.inputs.accordionGroup().textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; + return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight'; }); /** The keydown event manager for the accordion trigger. */ keydown = computed(() => { return new KeyboardEventManager() - .on(this.prevKey, () => this.inputs.accordionGroup().navigation.prev()) - .on(this.nextKey, () => this.inputs.accordionGroup().navigation.next()) - .on('Home', () => this.inputs.accordionGroup().navigation.first()) - .on('End', () => this.inputs.accordionGroup().navigation.last()) - .on(' ', () => this.expansionControl.toggle()) - .on('Enter', () => this.expansionControl.toggle()); + .on(this.prevKey, () => this.navigationBehavior.prev()) + .on(this.nextKey, () => this.navigationBehavior.next()) + .on('Home', () => this.navigationBehavior.first()) + .on('End', () => this.navigationBehavior.last()) + .on(' ', () => this.toggle()) + .on('Enter', () => this.toggle()); }); /** The pointerdown event manager for the accordion trigger. */ pointerdown = computed(() => { return new PointerEventManager().on(e => { - const item = this._getItem(e); + const item = this.inputs.getItem(e.target as Element); + if (!item) return; - if (item) { - this.inputs.accordionGroup().navigation.goto(item); - this.expansionControl.toggle(); - } + this.navigationBehavior.goto(item); + this.expansionBehavior.toggle(item); }); }); @@ -178,23 +107,90 @@ export class AccordionTriggerPattern { /** Handles focus events on the trigger. This ensures the tabbing changes the active index. */ onFocus(event: FocusEvent): void { - const item = this._getItem(event); + const item = this.inputs.getItem(event.target as Element); + if (!item) return; + if (!this.focusBehavior.isFocusable(item)) return; - if (item && this.inputs.accordionGroup().focusManager.isFocusable(item)) { - this.inputs.accordionGroup().focusManager.focus(item); - } + this.focusBehavior.focus(item); } - private _getItem(e: Event) { - if (!(e.target instanceof HTMLElement)) { - return; - } + /** Toggles the expansion state of the active accordion item. */ + toggle() { + const activeItem = this.inputs.activeItem(); + if (activeItem === undefined) return; + this.expansionBehavior.toggle(activeItem); + } +} - const element = e.target.closest('[role="button"]'); - return this.inputs - .accordionGroup() - .items() - .find(i => i.element() === element); +/** Inputs for the AccordionTriggerPattern. */ +export interface AccordionTriggerInputs + extends Omit, + Omit { + /** A local unique identifier for the trigger's corresponding panel. */ + panelId: SignalLike; + + /** The parent accordion group that controls this trigger. */ + accordionGroup: SignalLike; + + /** The accordion panel controlled by this trigger. */ + accordionPanel: SignalLike; +} + +/** A pattern controls the expansion state of an accordion. */ +export class AccordionTriggerPattern implements ListNavigationItem, ListFocusItem, ExpansionItem { + /** A unique identifier for this trigger. */ + readonly id: SignalLike = () => this.inputs.id(); + + /** A reference to the trigger element. */ + readonly element: SignalLike = () => this.inputs.element()!; + + /** Whether this trigger has expandable panel. */ + readonly expandable: SignalLike = () => true; + + /** Whether the corresponding panel is expanded. */ + readonly expanded: WritableSignalLike; + + /** Whether the trigger is active. */ + readonly active = computed(() => this.inputs.accordionGroup().inputs.activeItem() === this); + + /** Id of the accordion panel controlled by the trigger. */ + readonly controls = computed(() => this.inputs.accordionPanel()?.inputs.id()); + + /** The tabindex of the trigger. */ + readonly tabIndex = computed(() => + this.inputs.accordionGroup().focusBehavior.isFocusable(this) ? 0 : -1, + ); + + /** Whether the trigger is disabled. Disabling an accordion group disables all the triggers. */ + readonly disabled = computed( + () => this.inputs.disabled() || this.inputs.accordionGroup().inputs.disabled(), + ); + + /** Whether the trigger is hard disabled. */ + readonly hardDisabled = computed( + () => this.disabled() && !this.inputs.accordionGroup().inputs.softDisabled(), + ); + + /** The index of the trigger within its accordion group. */ + readonly index = computed(() => this.inputs.accordionGroup().inputs.items().indexOf(this)); + + constructor(readonly inputs: AccordionTriggerInputs) { + this.expanded = inputs.expanded; + } + + /** Opens the accordion panel. */ + open(): void { + this.inputs.accordionGroup().expansionBehavior.open(this); + } + + /** Closes the accordion panel. */ + close(): void { + this.inputs.accordionGroup().expansionBehavior.close(this); + } + + /** Toggles the accordion panel. */ + toggle(): void { + this.inputs.accordionGroup().expansionBehavior.toggle(this); } } @@ -210,15 +206,19 @@ export interface AccordionPanelInputs { accordionTrigger: SignalLike; } -export interface AccordionPanelPattern extends AccordionPanelInputs {} /** Represents an accordion panel. */ export class AccordionPanelPattern { + /** A global unique identifier for the panel. */ + id: SignalLike; + + /** The parent accordion trigger that controls this panel. */ + accordionTrigger: SignalLike; + /** 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.panelId = inputs.panelId; this.accordionTrigger = inputs.accordionTrigger; this.hidden = computed(() => inputs.accordionTrigger()?.expanded() === false); } diff --git a/src/aria/private/behaviors/expansion/expansion.spec.ts b/src/aria/private/behaviors/expansion/expansion.spec.ts index f1029267fcbe..5d956a8fc230 100644 --- a/src/aria/private/behaviors/expansion/expansion.spec.ts +++ b/src/aria/private/behaviors/expansion/expansion.spec.ts @@ -12,8 +12,8 @@ import {ListExpansion, ListExpansionInputs, ExpansionItem} from './expansion'; type TestItem = ExpansionItem & { id: WritableSignal; disabled: WritableSignal; + expanded: WritableSignal; expandable: WritableSignal; - expansionId: WritableSignal; }; type TestInputs = Partial> & { @@ -29,8 +29,8 @@ function createItems(length: number): WritableSignal { return { id: signal(itemId), disabled: signal(false), + expanded: signal(false), expandable: signal(true), - expansionId: signal(itemId), }; }), ); @@ -43,20 +43,25 @@ function getExpansion(inputs: TestInputs = {}): { const numItems = inputs.numItems ?? 3; const items = createItems(numItems); + for (const id of inputs.initialExpandedIds ?? []) { + items() + .find(i => i.id() === id) + ?.expanded.set(true); + } + const expansion = new ListExpansion({ items: items, disabled: signal(inputs.expansionDisabled ?? false), multiExpandable: signal(inputs.multiExpandable?.() ?? false), - expandedIds: signal([]), }); - if (inputs.initialExpandedIds) { - expansion.expandedIds.set(inputs.initialExpandedIds); - } - return {expansion, items: items()}; } +function getExpandedIds(items: TestItem[]): string[] { + return items.filter(i => i.expanded()).map(i => i.id()); +} + describe('Expansion', () => { describe('#open', () => { it('should open only one item at a time when multiExpandable is false', () => { @@ -65,10 +70,10 @@ describe('Expansion', () => { }); expansion.open(items[0]); - expect(expansion.expandedIds()).toEqual(['item-0']); + expect(getExpandedIds(items)).toEqual(['item-0']); expansion.open(items[1]); - expect(expansion.expandedIds()).toEqual(['item-1']); + expect(getExpandedIds(items)).toEqual(['item-1']); }); it('should open multiple items when multiExpandable is true', () => { @@ -77,24 +82,24 @@ describe('Expansion', () => { }); expansion.open(items[0]); - expect(expansion.expandedIds()).toEqual(['item-0']); + expect(getExpandedIds(items)).toEqual(['item-0']); expansion.open(items[1]); - expect(expansion.expandedIds()).toEqual(['item-0', 'item-1']); + expect(getExpandedIds(items)).toEqual(['item-0', 'item-1']); }); it('should not open an item if it is not expandable (expandable is false)', () => { const {expansion, items} = getExpansion(); items[1].expandable.set(false); expansion.open(items[1]); - expect(expansion.expandedIds()).toEqual([]); + expect(getExpandedIds(items)).toEqual([]); }); it('should not open an item if it is disabled', () => { const {expansion, items} = getExpansion(); items[1].disabled.set(true); expansion.open(items[1]); - expect(expansion.expandedIds()).toEqual([]); + expect(getExpandedIds(items)).toEqual([]); }); }); @@ -102,21 +107,21 @@ describe('Expansion', () => { it('should close the specified item', () => { const {expansion, items} = getExpansion({initialExpandedIds: ['item-0', 'item-1']}); expansion.close(items[0]); - expect(expansion.expandedIds()).toEqual(['item-1']); + expect(getExpandedIds(items)).toEqual(['item-1']); }); it('should not close an item if it is not expandable', () => { const {expansion, items} = getExpansion({initialExpandedIds: ['item-0']}); items[0].expandable.set(false); expansion.close(items[0]); - expect(expansion.expandedIds()).toEqual(['item-0']); + expect(getExpandedIds(items)).toEqual(['item-0']); }); it('should not close an item if it is disabled', () => { const {expansion, items} = getExpansion({initialExpandedIds: ['item-0']}); items[0].disabled.set(true); expansion.close(items[0]); - expect(expansion.expandedIds()).toEqual(['item-0']); + expect(getExpandedIds(items)).toEqual(['item-0']); }); }); @@ -124,7 +129,7 @@ describe('Expansion', () => { it('should open a closed item', () => { const {expansion, items} = getExpansion(); expansion.toggle(items[0]); - expect(expansion.expandedIds()).toEqual(['item-0']); + expect(getExpandedIds(items)).toEqual(['item-0']); }); it('should close an opened item', () => { @@ -132,18 +137,18 @@ describe('Expansion', () => { initialExpandedIds: ['item-0'], }); expansion.toggle(items[0]); - expect(expansion.expandedIds()).toEqual([]); + expect(getExpandedIds(items)).toEqual([]); }); }); describe('#openAll', () => { it('should open all focusable and expandable items when multiExpandable is true', () => { - const {expansion} = getExpansion({ + const {expansion, items} = getExpansion({ numItems: 3, multiExpandable: signal(true), }); expansion.openAll(); - expect(expansion.expandedIds()).toEqual(['item-0', 'item-1', 'item-2']); + expect(getExpandedIds(items)).toEqual(['item-0', 'item-1', 'item-2']); }); it('should not expand items that are not expandable', () => { @@ -153,7 +158,7 @@ describe('Expansion', () => { }); items[1].expandable.set(false); expansion.openAll(); - expect(expansion.expandedIds()).toEqual(['item-0', 'item-2']); + expect(getExpandedIds(items)).toEqual(['item-0', 'item-2']); }); it('should not expand items that are disabled', () => { @@ -163,16 +168,16 @@ describe('Expansion', () => { }); items[1].disabled.set(true); expansion.openAll(); - expect(expansion.expandedIds()).toEqual(['item-0', 'item-2']); + expect(getExpandedIds(items)).toEqual(['item-0', 'item-2']); }); it('should do nothing when multiExpandable is false', () => { - const {expansion} = getExpansion({ + const {expansion, items} = getExpansion({ numItems: 3, multiExpandable: signal(false), }); expansion.openAll(); - expect(expansion.expandedIds()).toEqual([]); + expect(getExpandedIds(items)).toEqual([]); }); }); @@ -184,7 +189,7 @@ describe('Expansion', () => { }); items[1].expandable.set(false); expansion.closeAll(); - expect(expansion.expandedIds()).toEqual([]); + expect(getExpandedIds(items)).toEqual([]); }); it('should not close items that are not expandable', () => { @@ -194,7 +199,7 @@ describe('Expansion', () => { }); items[1].expandable.set(false); expansion.closeAll(); - expect(expansion.expandedIds()).toEqual(['item-1']); + expect(getExpandedIds(items)).toEqual(['item-1']); }); it('should not close items that are disabled', () => { @@ -204,7 +209,7 @@ describe('Expansion', () => { }); items[1].disabled.set(true); expansion.closeAll(); - expect(expansion.expandedIds()).toEqual(['item-1']); + expect(getExpandedIds(items)).toEqual(['item-1']); }); }); @@ -236,13 +241,13 @@ describe('Expansion', () => { describe('#isExpanded', () => { it('should return true if item ID is in expandedIds', () => { - const {expansion, items} = getExpansion({initialExpandedIds: ['item-0']}); - expect(expansion.isExpanded(items[0])).toBeTrue(); + const {items} = getExpansion({initialExpandedIds: ['item-0']}); + expect(items[0].expanded()).toBeTrue(); }); it('should return false if item ID is not in expandedIds', () => { - const {expansion, items} = getExpansion(); - expect(expansion.isExpanded(items[0])).toBeFalse(); + const {items} = getExpansion(); + expect(items[0].expanded()).toBeFalse(); }); }); }); diff --git a/src/aria/private/behaviors/expansion/expansion.ts b/src/aria/private/behaviors/expansion/expansion.ts index 7c0ed74b9fe7..9a9caebd61e0 100644 --- a/src/aria/private/behaviors/expansion/expansion.ts +++ b/src/aria/private/behaviors/expansion/expansion.ts @@ -5,7 +5,6 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import {computed} from '@angular/core'; import {SignalLike, WritableSignalLike} from '../signal-like/signal-like'; /** Represents an item that can be expanded or collapsed. */ @@ -13,55 +12,18 @@ export interface ExpansionItem { /** Whether the item is expandable. */ expandable: SignalLike; - /** Used to uniquely identify an expansion item. */ - expansionId: SignalLike; + /** Whether the item is expanded. */ + expanded: WritableSignalLike; /** Whether the expansion is disabled. */ disabled: 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.disabled = inputs.disabled; - } - - /** Requests the Expansion 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 ListExpansionInputs { /** Whether multiple items can be expanded at once. */ multiExpandable: SignalLike; - /** An array of ids of the currently expanded items. */ - expandedIds: WritableSignalLike; - /** An array of expansion items. */ items: SignalLike; @@ -71,37 +33,34 @@ export interface ListExpansionInputs { /** Manages the expansion state of a list of items. */ export class ListExpansion { - /** A signal holding an array of ids of the currently expanded items. */ - expandedIds: WritableSignalLike; - - constructor(readonly inputs: ListExpansionInputs) { - this.expandedIds = inputs.expandedIds; - } + constructor(readonly inputs: ListExpansionInputs) {} /** Opens the specified item. */ - open(item: ExpansionItem) { - if (!this.isExpandable(item)) return; - if (this.isExpanded(item)) return; + open(item: ExpansionItem): boolean { + if (!this.isExpandable(item)) return false; + if (item.expanded()) return false; if (!this.inputs.multiExpandable()) { this.closeAll(); } - this.expandedIds.update(ids => ids.concat(item.expansionId())); + item.expanded.set(true); + return true; } /** Closes the specified item. */ - close(item: ExpansionItem) { - if (this.isExpandable(item)) { - this.expandedIds.update(ids => ids.filter(id => id !== item.expansionId())); - } + close(item: ExpansionItem): boolean { + if (!this.isExpandable(item)) return false; + + item.expanded.set(false); + return true; } /** Toggles the expansion state of the specified item. */ - toggle(item: ExpansionItem) { - this.expandedIds().includes(item.expansionId()) ? this.close(item) : this.open(item); + toggle(item: ExpansionItem): boolean { + return item.expanded() ? this.close(item) : this.open(item); } /** Opens all focusable items in the list. */ - openAll() { + openAll(): void { if (this.inputs.multiExpandable()) { for (const item of this.inputs.items()) { this.open(item); @@ -110,7 +69,7 @@ export class ListExpansion { } /** Closes all focusable items in the list. */ - closeAll() { + closeAll(): void { for (const item of this.inputs.items()) { this.close(item); } @@ -120,9 +79,4 @@ export class ListExpansion { isExpandable(item: ExpansionItem) { return !this.inputs.disabled() && !item.disabled() && item.expandable(); } - - /** Checks whether the specified item is currently expanded. */ - isExpanded(item: ExpansionItem): boolean { - return this.expandedIds().includes(item.expansionId()); - } } diff --git a/src/aria/private/combobox/combobox.spec.ts b/src/aria/private/combobox/combobox.spec.ts index 6dc275584a47..5c55fd763a3b 100644 --- a/src/aria/private/combobox/combobox.spec.ts +++ b/src/aria/private/combobox/combobox.spec.ts @@ -193,9 +193,10 @@ function getTreePattern( element.role = 'treeitem'; const treeItem = new TreeItemPattern({ value: signal(node.value), - id: signal('tree-item-' + tree.allItems().length), + id: signal('tree-item-' + tree.inputs.allItems().length), disabled: signal(false), selectable: signal(true), + expanded: signal(false), searchTerm: signal(node.value), tree: signal(tree), parent: signal(parent), @@ -204,13 +205,13 @@ function getTreePattern( children: signal([]), }); - (tree.allItems as WritableSignal[]>).update(items => + (tree.inputs.allItems as WritableSignal[]>).update(items => items.concat(treeItem), ); if (node.children) { const children = createTreeItems(node.children, treeItem); - (treeItem.children as WritableSignal[]>).set(children); + (treeItem.inputs.children as WritableSignal[]>).set(children); } return treeItem; diff --git a/src/aria/private/tabs/BUILD.bazel b/src/aria/private/tabs/BUILD.bazel index d22f3f920731..891074f610e6 100644 --- a/src/aria/private/tabs/BUILD.bazel +++ b/src/aria/private/tabs/BUILD.bazel @@ -12,7 +12,8 @@ ts_project( "//src/aria/private/behaviors/event-manager", "//src/aria/private/behaviors/expansion", "//src/aria/private/behaviors/label", - "//src/aria/private/behaviors/list", + "//src/aria/private/behaviors/list-focus", + "//src/aria/private/behaviors/list-navigation", "//src/aria/private/behaviors/signal-like", ], ) diff --git a/src/aria/private/tabs/tabs.spec.ts b/src/aria/private/tabs/tabs.spec.ts index 8dd35117d3ba..72e3829e7197 100644 --- a/src/aria/private/tabs/tabs.spec.ts +++ b/src/aria/private/tabs/tabs.spec.ts @@ -65,7 +65,6 @@ describe('Tabs Pattern', () => { activeItem: signal(undefined), softDisabled: signal(true), items: signal([]), - values: signal(['tab-1']), element: signal(document.createElement('div')), }; tabListPattern = new TabListPattern(tabListInputs); @@ -79,6 +78,7 @@ describe('Tabs Pattern', () => { element: signal(createTabElement()), disabled: signal(false), value: signal('tab-1'), + expanded: signal(false), }, { tablist: signal(tabListPattern), @@ -87,6 +87,7 @@ describe('Tabs Pattern', () => { element: signal(createTabElement()), disabled: signal(false), value: signal('tab-2'), + expanded: signal(false), }, { tablist: signal(tabListPattern), @@ -95,6 +96,7 @@ describe('Tabs Pattern', () => { element: signal(createTabElement()), disabled: signal(false), value: signal('tab-3'), + expanded: signal(false), }, ]; tabPatterns = [ @@ -138,227 +140,256 @@ describe('Tabs Pattern', () => { tabListInputs.activeItem.set(tabPatterns[0]); }); - it('sets the selected tab by setting `value`.', () => { - expect(tabPatterns[0].selected()).toBeTrue(); - expect(tabPatterns[1].selected()).toBeFalse(); - tabListInputs.values.set(['tab-2']); - expect(tabPatterns[0].selected()).toBeFalse(); - expect(tabPatterns[1].selected()).toBeTrue(); - }); - - it('sets a tabpanel to be not hidden if a tab is selected.', () => { - tabListInputs.values.set(['tab-1']); - expect(tabPatterns[0].selected()).toBeTrue(); - expect(tabPanelPatterns[0].hidden()).toBeFalse(); - }); - - it('sets a tabpanel to be hidden if a tab is not selected.', () => { - tabListInputs.values.set(['tab-1']); - expect(tabPatterns[1].selected()).toBeFalse(); - expect(tabPanelPatterns[1].hidden()).toBeTrue(); - }); - - it('should set a tabpanel tab index to 0 if the tab is selected.', () => { - tabListInputs.values.set(['tab-1']); - expect(tabPatterns[0].tabIndex()).toBe(0); - }); - - it('should set a tabpanel tab index to -1 if the tab is not selected.', () => { - tabListInputs.values.set(['tab-1']); - expect(tabPatterns[1].tabIndex()).toBe(-1); - expect(tabPatterns[2].tabIndex()).toBe(-1); - }); - - it('should set a tabpanel aria-labelledby pointing to its tab id.', () => { - expect(tabPanelPatterns[0].labelledBy()).toBe('tab-1-id'); - expect(tabPanelPatterns[1].labelledBy()).toBe('tab-2-id'); - expect(tabPanelPatterns[2].labelledBy()).toBe('tab-3-id'); - }); - - it('gets a controlled tabpanel id from a tab.', () => { - expect(tabPanelPatterns[0].id()).toBe('tabpanel-1-id'); - expect(tabPatterns[0].controls()).toBe('tabpanel-1-id'); - expect(tabPanelPatterns[1].id()).toBe('tabpanel-2-id'); - expect(tabPatterns[1].controls()).toBe('tabpanel-2-id'); - expect(tabPanelPatterns[2].id()).toBe('tabpanel-3-id'); - expect(tabPatterns[2].controls()).toBe('tabpanel-3-id'); - }); - - describe('#setDefaultState', () => { - it('should not set activeIndex if there are no tabs', () => { - tabListInputs.items.set([]); - tabListInputs.activeItem.set(tabPatterns[10]); - tabListPattern.setDefaultState(); - expect(tabListInputs.activeItem()).toBe(tabPatterns[10]); - }); - - it('should not set activeIndex if no tabs are focusable', () => { - tabListInputs.softDisabled.set(false); - tabInputs.forEach(input => input.disabled.set(true)); - tabListInputs.activeItem.set(tabPatterns[10]); - tabListPattern.setDefaultState(); - expect(tabListInputs.activeItem()).toBe(tabPatterns[10]); - }); - - it('should set activeIndex to the first focusable tab if no tabs are selected', () => { - tabListInputs.softDisabled.set(false); - tabListInputs.activeItem.set(tabPatterns[2]); - tabListInputs.values.set([]); - tabInputs[0].disabled.set(true); - tabListPattern.setDefaultState(); - expect(tabListInputs.activeItem()).toBe(tabPatterns[1]); + describe('TabListPattern', () => { + describe('#open', () => { + it('should open a tab with value', () => { + expect(tabListPattern.selectedTab()).toBeUndefined(); + tabListPattern.open('tab-1'); + expect(tabListPattern.selectedTab()!.value()).toBe('tab-1'); + }); + + it('should open a tab with tab pattern instance', () => { + expect(tabListPattern.selectedTab()).toBeUndefined(); + tabListPattern.open(tabPatterns[0]); + expect(tabListPattern.selectedTab()).toBe(tabPatterns[0]); + }); + + it('should open the active tab', () => { + expect(tabListPattern.selectedTab()).toBeUndefined(); + expect(tabListPattern.activeTab()).toBe(tabPatterns[0]); + tabListPattern.open(); + expect(tabListPattern.selectedTab()).toBe(tabPatterns[0]); + }); }); - it('should set activeIndex to the first focusable and selected tab', () => { - tabListInputs.activeItem.set(tabPatterns[0]); - tabListInputs.values.set([tabPatterns[2].value()]); - tabListPattern.setDefaultState(); - expect(tabListInputs.activeItem()).toBe(tabPatterns[2]); + describe('#setDefaultState', () => { + it('should not set activeIndex if there are no tabs', () => { + tabListInputs.items.set([]); + tabListInputs.activeItem.set(tabPatterns[10]); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeItem()).toBe(tabPatterns[10]); + }); + + it('should not set activeIndex if no tabs are focusable', () => { + tabListInputs.softDisabled.set(false); + tabInputs.forEach(input => input.disabled.set(true)); + tabListInputs.activeItem.set(tabPatterns[10]); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeItem()).toBe(tabPatterns[10]); + }); + + it('should set activeIndex to the first focusable tab if no tabs are selected', () => { + tabListInputs.softDisabled.set(false); + tabListInputs.activeItem.set(tabPatterns[2]); + tabListPattern.selectedTab.set(undefined); + tabInputs[0].disabled.set(true); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeItem()).toBe(tabPatterns[1]); + }); + + it('should set activeIndex to the first focusable and selected tab', () => { + tabListInputs.activeItem.set(tabPatterns[0]); + tabListPattern.selectedTab.set(tabPatterns[2]); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeItem()).toBe(tabPatterns[2]); + }); + + it('should set activeIndex to the first focusable tab when the selected tab is not focusable', () => { + tabListInputs.softDisabled.set(false); + tabListPattern.selectedTab.set(tabPatterns[1]); + tabInputs[1].disabled.set(true); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeItem()).toBe(tabPatterns[0]); + }); }); - it('should set activeIndex to the first focusable tab when the selected tab is not focusable', () => { - tabListInputs.softDisabled.set(false); - tabListInputs.values.set([tabPatterns[1].value()]); - tabInputs[1].disabled.set(true); - tabListPattern.setDefaultState(); - expect(tabListInputs.activeItem()).toBe(tabPatterns[0]); + describe('Keyboard Navigation', () => { + it('does not handle keyboard event if a tablist is disabled.', () => { + expect(tabPatterns[1].active()).toBeFalse(); + tabListInputs.disabled.set(true); + tabListPattern.onKeydown(right()); + expect(tabPatterns[1].active()).toBeFalse(); + }); + + it('skips the disabled tab when `softDisabled` is set to false.', () => { + tabListInputs.softDisabled.set(false); + tabInputs[1].disabled.set(true); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].active()).toBeFalse(); + expect(tabPatterns[1].active()).toBeFalse(); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('does not skip the disabled tab when `softDisabled` is set to true.', () => { + tabInputs[1].disabled.set(true); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].active()).toBeFalse(); + expect(tabPatterns[1].active()).toBeTrue(); + expect(tabPatterns[2].active()).toBeFalse(); + }); + + it('selects a tab by focus if `selectionMode` is "follow".', () => { + tabListPattern.onKeydown(space()); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].selected()).toBeFalse(); + expect(tabPatterns[1].selected()).toBeTrue(); + }); + + it('selects a tab by enter key if `selectionMode` is "explicit".', () => { + tabListInputs.selectionMode.set('explicit'); + tabListPattern.onKeydown(space()); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(enter()); + expect(tabPatterns[0].selected()).toBeFalse(); + expect(tabPatterns[1].selected()).toBeTrue(); + }); + + it('selects a tab by space key if `selectionMode` is "explicit".', () => { + tabListInputs.selectionMode.set('explicit'); + tabListPattern.onKeydown(space()); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(space()); + expect(tabPatterns[0].selected()).toBeFalse(); + expect(tabPatterns[1].selected()).toBeTrue(); + }); + + it('uses left key to navigate to the previous tab when `orientation` is set to "horizontal".', () => { + tabListInputs.activeItem.set(tabPatterns[1]); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(left()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('uses right key to navigate to the next tab when `orientation` is set to "horizontal".', () => { + tabListInputs.activeItem.set(tabPatterns[1]); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('uses up key to navigate to the previous tab when `orientation` is set to "vertical".', () => { + tabListInputs.orientation.set('vertical'); + tabListInputs.activeItem.set(tabPatterns[1]); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(up()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('uses down key to navigate to the next tab when `orientation` is set to "vertical".', () => { + tabListInputs.orientation.set('vertical'); + tabListInputs.activeItem.set(tabPatterns[1]); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(down()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('uses home key to navigate to the first tab.', () => { + tabListInputs.activeItem.set(tabPatterns[1]); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(home()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('uses end key to navigate to the last tab.', () => { + tabListInputs.activeItem.set(tabPatterns[1]); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(end()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('moves to the last tab from first tab when navigating to the previous tab if `wrap` is set to true', () => { + expect(tabPatterns[0].active()).toBeTrue(); + tabListPattern.onKeydown(left()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('moves to the first tab from last tab when navigating to the next tab if `wrap` is set to true', () => { + tabListPattern.onKeydown(end()); + expect(tabPatterns[2].active()).toBeTrue(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('stays on the first tab when navigating to the previous tab if `wrap` is set to false', () => { + tabListInputs.wrap.set(false); + expect(tabPatterns[0].active()).toBeTrue(); + tabListPattern.onKeydown(left()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('stays on the last tab when navigating to the next tab if `wrap` is set to false', () => { + tabListInputs.wrap.set(false); + tabListPattern.onKeydown(end()); + expect(tabPatterns[2].active()).toBeTrue(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('changes the navigation direction with `rtl` mode.', () => { + tabListInputs.textDirection.set('rtl'); + tabListInputs.activeItem.set(tabPatterns[1]); + tabListPattern.onKeydown(left()); + expect(tabPatterns[2].active()).toBeTrue(); + }); }); }); - describe('Keyboard Navigation', () => { - it('does not handle keyboard event if a tablist is disabled.', () => { - expect(tabPatterns[1].active()).toBeFalse(); - tabListInputs.disabled.set(true); - tabListPattern.onKeydown(right()); - expect(tabPatterns[1].active()).toBeFalse(); + describe('TabPattern', () => { + it('gets a controlled tabpanel id from a tab', () => { + expect(tabPanelPatterns[0].id()).toBe('tabpanel-1-id'); + expect(tabPatterns[0].controls()).toBe('tabpanel-1-id'); + expect(tabPanelPatterns[1].id()).toBe('tabpanel-2-id'); + expect(tabPatterns[1].controls()).toBe('tabpanel-2-id'); + expect(tabPanelPatterns[2].id()).toBe('tabpanel-3-id'); + expect(tabPatterns[2].controls()).toBe('tabpanel-3-id'); }); - it('skips the disabled tab when `softDisabled` is set to false.', () => { - tabListInputs.softDisabled.set(false); - tabInputs[1].disabled.set(true); - tabListPattern.onKeydown(right()); - expect(tabPatterns[0].active()).toBeFalse(); - expect(tabPatterns[1].active()).toBeFalse(); - expect(tabPatterns[2].active()).toBeTrue(); - }); - - it('does not skip the disabled tab when `softDisabled` is set to true.', () => { - tabInputs[1].disabled.set(true); - tabListPattern.onKeydown(right()); - expect(tabPatterns[0].active()).toBeFalse(); - expect(tabPatterns[1].active()).toBeTrue(); - expect(tabPatterns[2].active()).toBeFalse(); - }); - - it('selects a tab by focus if `selectionMode` is "follow".', () => { - expect(tabPatterns[0].selected()).toBeTrue(); - expect(tabPatterns[1].selected()).toBeFalse(); - tabListPattern.onKeydown(right()); - expect(tabPatterns[0].selected()).toBeFalse(); - expect(tabPatterns[1].selected()).toBeTrue(); + describe('#open', () => { + it('should open the current tab', () => { + expect(tabListPattern.selectedTab()).toBeUndefined(); + tabPatterns[0].open(); + expect(tabListPattern.selectedTab()).toBe(tabPatterns[0]); + }); }); + }); - it('selects a tab by enter key if `selectionMode` is "explicit".', () => { - tabListInputs.selectionMode.set('explicit'); - expect(tabPatterns[0].selected()).toBeTrue(); - expect(tabPatterns[1].selected()).toBeFalse(); - tabListPattern.onKeydown(right()); + describe('TabPanelPattern', () => { + it('should set a tabpanel to be not hidden if a tab is opened', () => { + tabPatterns[0].open(); expect(tabPatterns[0].selected()).toBeTrue(); - expect(tabPatterns[1].selected()).toBeFalse(); - tabListPattern.onKeydown(enter()); - expect(tabPatterns[0].selected()).toBeFalse(); - expect(tabPatterns[1].selected()).toBeTrue(); + expect(tabPanelPatterns[0].hidden()).toBeFalse(); }); - it('selects a tab by space key if `selectionMode` is "explicit".', () => { - tabListInputs.selectionMode.set('explicit'); - expect(tabPatterns[0].selected()).toBeTrue(); - expect(tabPatterns[1].selected()).toBeFalse(); - tabListPattern.onKeydown(right()); - expect(tabPatterns[0].selected()).toBeTrue(); + it('sets a tabpanel to be hidden if a tab is not opened', () => { expect(tabPatterns[1].selected()).toBeFalse(); - tabListPattern.onKeydown(space()); - expect(tabPatterns[0].selected()).toBeFalse(); - expect(tabPatterns[1].selected()).toBeTrue(); - }); - - it('uses left key to navigate to the previous tab when `orientation` is set to "horizontal".', () => { - tabListInputs.activeItem.set(tabPatterns[1]); - expect(tabPatterns[1].active()).toBeTrue(); - tabListPattern.onKeydown(left()); - expect(tabPatterns[0].active()).toBeTrue(); - }); - - it('uses right key to navigate to the next tab when `orientation` is set to "horizontal".', () => { - tabListInputs.activeItem.set(tabPatterns[1]); - expect(tabPatterns[1].active()).toBeTrue(); - tabListPattern.onKeydown(right()); - expect(tabPatterns[2].active()).toBeTrue(); - }); - - it('uses up key to navigate to the previous tab when `orientation` is set to "vertical".', () => { - tabListInputs.orientation.set('vertical'); - tabListInputs.activeItem.set(tabPatterns[1]); - expect(tabPatterns[1].active()).toBeTrue(); - tabListPattern.onKeydown(up()); - expect(tabPatterns[0].active()).toBeTrue(); - }); - - it('uses down key to navigate to the next tab when `orientation` is set to "vertical".', () => { - tabListInputs.orientation.set('vertical'); - tabListInputs.activeItem.set(tabPatterns[1]); - expect(tabPatterns[1].active()).toBeTrue(); - tabListPattern.onKeydown(down()); - expect(tabPatterns[2].active()).toBeTrue(); - }); - - it('uses home key to navigate to the first tab.', () => { - tabListInputs.activeItem.set(tabPatterns[1]); - expect(tabPatterns[1].active()).toBeTrue(); - tabListPattern.onKeydown(home()); - expect(tabPatterns[0].active()).toBeTrue(); - }); - - it('uses end key to navigate to the last tab.', () => { - tabListInputs.activeItem.set(tabPatterns[1]); - expect(tabPatterns[1].active()).toBeTrue(); - tabListPattern.onKeydown(end()); - expect(tabPatterns[2].active()).toBeTrue(); - }); - - it('moves to the last tab from first tab when navigating to the previous tab if `wrap` is set to true', () => { - expect(tabPatterns[0].active()).toBeTrue(); - tabListPattern.onKeydown(left()); - expect(tabPatterns[2].active()).toBeTrue(); - }); - - it('moves to the first tab from last tab when navigating to the next tab if `wrap` is set to true', () => { - tabListPattern.onKeydown(end()); - expect(tabPatterns[2].active()).toBeTrue(); - tabListPattern.onKeydown(right()); - expect(tabPatterns[0].active()).toBeTrue(); + expect(tabPanelPatterns[1].hidden()).toBeTrue(); }); - it('stays on the first tab when navigating to the previous tab if `wrap` is set to false', () => { - tabListInputs.wrap.set(false); - expect(tabPatterns[0].active()).toBeTrue(); - tabListPattern.onKeydown(left()); - expect(tabPatterns[0].active()).toBeTrue(); + it('should set a tabpanel tab index to 0 if the tab is opened', () => { + tabPatterns[0].open(); + expect(tabPatterns[0].tabIndex()).toBe(0); }); - it('stays on the last tab when navigating to the next tab if `wrap` is set to false', () => { - tabListInputs.wrap.set(false); - tabListPattern.onKeydown(end()); - expect(tabPatterns[2].active()).toBeTrue(); - tabListPattern.onKeydown(right()); - expect(tabPatterns[2].active()).toBeTrue(); + it('should set a tabpanel tab index to -1 if the tab is not opened', () => { + tabPatterns[0].open(); + expect(tabPatterns[1].tabIndex()).toBe(-1); + expect(tabPatterns[2].tabIndex()).toBe(-1); }); - it('changes the navigation direction with `rtl` mode.', () => { - tabListInputs.textDirection.set('rtl'); - tabListInputs.activeItem.set(tabPatterns[1]); - tabListPattern.onKeydown(left()); - expect(tabPatterns[2].active()).toBeTrue(); + it('should set a tabpanel aria-labelledby pointing to its tab id', () => { + expect(tabPanelPatterns[0].labelledBy()).toBe('tab-1-id'); + expect(tabPanelPatterns[1].labelledBy()).toBe('tab-2-id'); + expect(tabPanelPatterns[2].labelledBy()).toBe('tab-3-id'); }); }); }); diff --git a/src/aria/private/tabs/tabs.ts b/src/aria/private/tabs/tabs.ts index 50c4dc782175..07b4231f6327 100644 --- a/src/aria/private/tabs/tabs.ts +++ b/src/aria/private/tabs/tabs.ts @@ -6,106 +6,96 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed} from '@angular/core'; +import {computed, signal, WritableSignal} from '@angular/core'; import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; -import { - ExpansionItem, - ExpansionControl, - ListExpansionInputs, - ListExpansion, -} from '../behaviors/expansion/expansion'; -import {SignalLike} from '../behaviors/signal-like/signal-like'; +import {ExpansionItem, ListExpansionInputs, ListExpansion} from '../behaviors/expansion/expansion'; +import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; import {LabelControl, LabelControlOptionalInputs} from '../behaviors/label/label'; -import {List, ListInputs, ListItem} from '../behaviors/list/list'; +import {ListFocus} from '../behaviors/list-focus/list-focus'; +import { + ListNavigationItem, + ListNavigation, + ListNavigationInputs, +} from '../behaviors/list-navigation/list-navigation'; /** The required inputs to tabs. */ export interface TabInputs - extends Omit, 'searchTerm' | 'index' | 'selectable'>, - Omit { + extends Omit, + Omit { /** The parent tablist that controls the tab. */ tablist: SignalLike; /** The remote tabpanel controlled by the tab. */ tabpanel: SignalLike; + + /** The remote tabpanel unique identifier. */ + value: SignalLike; } /** A tab in a tablist. */ export class TabPattern { - /** Controls expansion for this tab. */ - readonly expansion: ExpansionControl; - /** A global unique identifier for the tab. */ - readonly id: SignalLike; + readonly id: SignalLike = () => this.inputs.id(); /** The index of the tab. */ readonly index = computed(() => this.inputs.tablist().inputs.items().indexOf(this)); - /** A local unique identifier for the tab. */ - readonly value: SignalLike; + /** The remote tabpanel unique identifier. */ + readonly value: SignalLike = () => this.inputs.value(); /** Whether the tab is disabled. */ - readonly disabled: SignalLike; + readonly disabled: SignalLike = () => this.inputs.disabled(); /** The html element that should receive focus. */ - readonly element: SignalLike; + readonly element: SignalLike = () => this.inputs.element()!; - /** Whether the tab is selectable. */ - readonly selectable = () => true; + /** Whether this tab has expandable panel. */ + readonly expandable: SignalLike = () => true; - /** The text used by the typeahead search. */ - readonly searchTerm = () => ''; // Unused because tabs do not support typeahead. - - /** Whether this tab has expandable content. */ - readonly expandable = computed(() => this.expansion.expandable()); - - /** The unique identifier used by the expansion behavior. */ - readonly expansionId = computed(() => this.expansion.expansionId()); - - /** Whether the tab is expanded. */ - readonly expanded = computed(() => this.expansion.isExpanded()); + /** Whether the tab panel is expanded. */ + readonly expanded: WritableSignalLike; /** Whether the tab is active. */ readonly active = computed(() => this.inputs.tablist().inputs.activeItem() === this); /** Whether the tab is selected. */ - readonly selected = computed( - () => !!this.inputs.tablist().inputs.values().includes(this.value()), - ); + readonly selected = computed(() => this.inputs.tablist().selectedTab() === this); /** The tab index of the tab. */ - readonly tabIndex = computed(() => this.inputs.tablist().listBehavior.getItemTabindex(this)); + readonly tabIndex = computed(() => this.inputs.tablist().focusBehavior.getItemTabIndex(this)); /** The id of the tabpanel associated with the tab. */ readonly controls = computed(() => this.inputs.tabpanel()?.id()); constructor(readonly inputs: TabInputs) { - this.id = inputs.id; - this.value = inputs.value; - this.disabled = inputs.disabled; - this.element = inputs.element; - this.expansion = new ExpansionControl({ - ...inputs, - expansionId: inputs.value, - expandable: () => true, - expansionManager: inputs.tablist().expansionManager, - }); + this.expanded = inputs.expanded; + } + + /** Opens the tab. */ + open(): boolean { + return this.inputs.tablist().open(this); } } /** The required inputs for the tabpanel. */ export interface TabPanelInputs extends LabelControlOptionalInputs { + /** A global unique identifier for the tabpanel. */ id: SignalLike; + + /** The tab that controls this tabpanel. */ tab: SignalLike; + + /** A local unique identifier for the tabpanel. */ value: SignalLike; } /** A tabpanel associated with a tab. */ export class TabPanelPattern { /** A global unique identifier for the tabpanel. */ - readonly id: SignalLike; + readonly id: SignalLike = () => this.inputs.id(); /** A local unique identifier for the tabpanel. */ - readonly value: SignalLike; + readonly value: SignalLike = () => this.inputs.value(); /** Controls label for this tabpanel. */ readonly labelManager: LabelControl; @@ -124,8 +114,6 @@ export class TabPanelPattern { ); constructor(readonly inputs: TabPanelInputs) { - this.id = inputs.id; - this.value = inputs.value; this.labelManager = new LabelControl({ ...inputs, defaultLabelledBy: computed(() => (this.inputs.tab() ? [this.inputs.tab()!.id()] : [])), @@ -134,28 +122,41 @@ export class TabPanelPattern { } /** The required inputs for the tablist. */ -export type TabListInputs = Omit, 'multi' | 'typeaheadDelay'> & - Omit; +export interface TabListInputs + extends Omit, 'multi'>, + Omit { + /** The selection strategy used by the tablist. */ + selectionMode: SignalLike<'follow' | 'explicit'>; +} /** Controls the state of a tablist. */ export class TabListPattern { - /** The list behavior for the tablist. */ - readonly listBehavior: List; + /** The list focus behavior for the tablist. */ + readonly focusBehavior: ListFocus; + + /** The list navigation behavior for the tablist. */ + readonly navigationBehavior: ListNavigation; /** Controls expansion for the tablist. */ - readonly expansionManager: ListExpansion; + readonly expansionBehavior: ListExpansion; + + /** The currently active tab. */ + readonly activeTab: SignalLike = () => this.inputs.activeItem(); + + /** The currently selected tab. */ + readonly selectedTab: WritableSignal = signal(undefined); /** Whether the tablist is vertically or horizontally oriented. */ - readonly orientation: SignalLike<'vertical' | 'horizontal'>; + readonly orientation: SignalLike<'vertical' | 'horizontal'> = () => this.inputs.orientation(); /** Whether the tablist is disabled. */ - readonly disabled: SignalLike; + readonly disabled: SignalLike = () => this.inputs.disabled(); /** The tab index of the tablist. */ - readonly tabIndex = computed(() => this.listBehavior.tabIndex()); + readonly tabIndex = computed(() => this.focusBehavior.getListTabIndex()); /** The id of the current active tab. */ - readonly activeDescendant = computed(() => this.listBehavior.activeDescendant()); + readonly activeDescendant = computed(() => this.focusBehavior.getActiveDescendant()); /** Whether selection should follow focus. */ readonly followFocus = computed(() => this.inputs.selectionMode() === 'follow'); @@ -179,35 +180,36 @@ export class TabListPattern { /** The keydown event manager for the tablist. */ readonly keydown = computed(() => { return new KeyboardEventManager() - .on(this.prevKey, () => this.listBehavior.prev({select: this.followFocus()})) - .on(this.nextKey, () => this.listBehavior.next({select: this.followFocus()})) - .on('Home', () => this.listBehavior.first({select: this.followFocus()})) - .on('End', () => this.listBehavior.last({select: this.followFocus()})) - .on(' ', () => this.listBehavior.select()) - .on('Enter', () => this.listBehavior.select()); + .on(this.prevKey, () => + this._navigate(() => this.navigationBehavior.prev(), this.followFocus()), + ) + .on(this.nextKey, () => + this._navigate(() => this.navigationBehavior.next(), this.followFocus()), + ) + .on('Home', () => this._navigate(() => this.navigationBehavior.first(), this.followFocus())) + .on('End', () => this._navigate(() => this.navigationBehavior.last(), this.followFocus())) + .on(' ', () => this.open()) + .on('Enter', () => this.open()); }); /** The pointerdown event manager for the tablist. */ readonly pointerdown = computed(() => { return new PointerEventManager().on(e => - this.listBehavior.goto(this._getItem(e)!, {select: true}), + this._navigate(() => this.navigationBehavior.goto(this._getItem(e)!), true), ); }); constructor(readonly inputs: TabListInputs) { - this.disabled = inputs.disabled; - this.orientation = inputs.orientation; + this.focusBehavior = new ListFocus(inputs); - this.listBehavior = new List({ + this.navigationBehavior = new ListNavigation({ ...inputs, - multi: () => false, - typeaheadDelay: () => 0, // Tabs do not support typeahead. + focusManager: this.focusBehavior, }); - this.expansionManager = new ListExpansion({ + this.expansionBehavior = new ListExpansion({ ...inputs, multiExpandable: () => false, - expandedIds: this.inputs.values, }); } @@ -223,7 +225,7 @@ export class TabListPattern { let firstItem: TabPattern | undefined; for (const item of this.inputs.items()) { - if (!this.listBehavior.isFocusable(item)) continue; + if (!this.focusBehavior.isFocusable(item)) continue; if (firstItem === undefined) { firstItem = item; @@ -253,6 +255,37 @@ export class TabListPattern { } } + /** Opens the tab by given value. */ + open(value: string): boolean; + + /** Opens the given tab or the current active tab. */ + open(tab?: TabPattern): boolean; + + open(tab: TabPattern | string | undefined): boolean { + tab ??= this.activeTab(); + + if (typeof tab === 'string') { + tab = this.inputs.items().find(t => t.value() === tab); + } + + if (tab === undefined) return false; + + const success = this.expansionBehavior.open(tab); + if (success) { + this.selectedTab.set(tab); + } + + return success; + } + + /** Executes a navigation operation and expand the active tab if needed. */ + private _navigate(op: () => boolean, shouldExpand: boolean = false): void { + const success = op(); + if (success && shouldExpand) { + this.open(); + } + } + /** Returns the tab item associated with the given pointer event. */ private _getItem(e: PointerEvent) { if (!(e.target instanceof HTMLElement)) { diff --git a/src/aria/private/tree/combobox-tree.ts b/src/aria/private/tree/combobox-tree.ts index 5e2120ce1e1f..46ab5c5c633c 100644 --- a/src/aria/private/tree/combobox-tree.ts +++ b/src/aria/private/tree/combobox-tree.ts @@ -106,10 +106,10 @@ export class ComboboxTreePattern } /** Expands all of the tree items. */ - expandAll = () => this.items().forEach(item => item.expansion.open()); + expandAll = () => this.items().forEach(item => this.expansionBehavior.open(item)); /** Collapses all of the tree items. */ - collapseAll = () => this.items().forEach(item => item.expansion.close()); + collapseAll = () => this.items().forEach(item => item.expansionBehavior.close(item)); /** Whether the currently active item is selectable. */ isItemSelectable = (item: TreeItemPattern | undefined = this.inputs.activeItem()) => { diff --git a/src/aria/private/tree/tree.spec.ts b/src/aria/private/tree/tree.spec.ts index 4da67ae80179..94972c167595 100644 --- a/src/aria/private/tree/tree.spec.ts +++ b/src/aria/private/tree/tree.spec.ts @@ -58,6 +58,7 @@ interface TestTreeItem { children?: TestTreeItem[]; disabled: boolean; selectable: boolean; + expanded: boolean; } describe('Tree Pattern', () => { @@ -86,6 +87,7 @@ describe('Tree Pattern', () => { element: signal(element), disabled: signal(node.disabled), selectable: signal(node.selectable), + expanded: signal(node.expanded), searchTerm: signal(String(node.value)), parent: signal(parent), hasChildren: signal((node.children ?? []).length > 0), @@ -120,18 +122,20 @@ describe('Tree Pattern', () => { { value: 'Item 0', children: [ - {value: 'Item 0-0', disabled: false, selectable: true}, - {value: 'Item 0-1', disabled: false, selectable: true}, + {value: 'Item 0-0', disabled: false, selectable: true, expanded: false}, + {value: 'Item 0-1', disabled: false, selectable: true, expanded: false}, ], disabled: false, selectable: true, + expanded: false, }, - {value: 'Item 1', disabled: false, selectable: true}, + {value: 'Item 1', disabled: false, selectable: true, expanded: false}, { value: 'Item 2', - children: [{value: 'Item 2-0', disabled: false, selectable: true}], + children: [{value: 'Item 2-0', disabled: false, selectable: true, expanded: false}], disabled: false, selectable: true, + expanded: false, }, ]; @@ -387,9 +391,9 @@ describe('Tree Pattern', () => { it('should skip disabled items when softDisabled is false', () => { treeInputs.softDisabled.set(false); const localTreeExample: TestTreeItem[] = [ - {value: 'Item A', disabled: false, selectable: true}, - {value: 'Item B', disabled: true, selectable: true}, - {value: 'Item C', disabled: false, selectable: true}, + {value: 'Item A', disabled: false, selectable: true, expanded: false}, + {value: 'Item B', disabled: true, selectable: true, expanded: false}, + {value: 'Item C', disabled: false, selectable: true, expanded: false}, ]; const {tree, allItems} = createTree(localTreeExample, treeInputs); const itemA = getItemByValue(allItems(), 'Item A'); @@ -404,9 +408,9 @@ describe('Tree Pattern', () => { it('should not skip disabled items when softDisabled is true', () => { treeInputs.softDisabled.set(true); const localTreeExample: TestTreeItem[] = [ - {value: 'Item A', disabled: false, selectable: true}, - {value: 'Item B', disabled: true, selectable: true}, - {value: 'Item C', disabled: false, selectable: true}, + {value: 'Item A', disabled: false, selectable: true, expanded: false}, + {value: 'Item B', disabled: true, selectable: true, expanded: false}, + {value: 'Item C', disabled: false, selectable: true, expanded: false}, ]; const {tree, allItems} = createTree(localTreeExample, treeInputs); const itemA = getItemByValue(allItems(), 'Item A'); @@ -628,7 +632,7 @@ describe('Tree Pattern', () => { it('should select a range of visible items on Shift + ArrowDown/ArrowUp', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - item0.expansion.open(); + item0.expanded.set(true); tree.onKeydown(shift()); tree.onKeydown(down({shift: true})); @@ -654,7 +658,7 @@ describe('Tree Pattern', () => { it('should select a range of visible items on Shift + Space (or Enter)', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - item0.expansion.open(); + item0.expanded.set(true); tree.onKeydown(down()); tree.onKeydown(space()); @@ -671,7 +675,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); - item0.expansion.open(); + item0.expanded.set(true); tree.listBehavior.goto(item1); tree.onKeydown(shift()); @@ -683,7 +687,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); - item0.expansion.open(); + item0.expanded.set(true); tree.listBehavior.goto(item0_0); tree.onKeydown(shift()); @@ -704,9 +708,9 @@ describe('Tree Pattern', () => { it('should not select disabled items on Shift + ArrowUp / ArrowDown', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false, selectable: true}, - {value: 'B', disabled: true, selectable: true}, - {value: 'C', disabled: false, selectable: true}, + {value: 'A', disabled: false, selectable: true, expanded: false}, + {value: 'B', disabled: true, selectable: true, expanded: false}, + {value: 'C', disabled: false, selectable: true, expanded: false}, ]; treeInputs.softDisabled.set(true); const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -721,9 +725,9 @@ describe('Tree Pattern', () => { it('should not select non-selectable items on Shift + ArrowUp / ArrowDown', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false, selectable: true}, - {value: 'B', disabled: false, selectable: false}, - {value: 'C', disabled: false, selectable: true}, + {value: 'A', disabled: false, selectable: true, expanded: false}, + {value: 'B', disabled: false, selectable: false, expanded: false}, + {value: 'C', disabled: false, selectable: true, expanded: false}, ]; const {tree, allItems} = createTree(localTreeData, treeInputs); const itemA = getItemByValue(allItems(), 'A'); @@ -738,7 +742,7 @@ describe('Tree Pattern', () => { it('should select all visible items on Ctrl + A', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - item0.expansion.open(); + item0.expanded.set(true); tree.onKeydown(a({control: true})); expect(tree.inputs.values()).toEqual([ @@ -753,7 +757,7 @@ describe('Tree Pattern', () => { it('should deselect all visible items on Ctrl + A if all are selected', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - item0.expansion.open(); + item0.expanded.set(true); tree.onKeydown(a({control: true})); tree.onKeydown(a({control: true})); @@ -817,7 +821,7 @@ describe('Tree Pattern', () => { treeInputs.values.set(['Item 0']); const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - item0.expansion.open(); + item0.expanded.set(true); tree.onKeydown(shift()); tree.onKeydown(down({shift: true})); @@ -840,7 +844,7 @@ describe('Tree Pattern', () => { it('should select a range of visible items on Shift + Space (or Enter)', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); - item0.expansion.open(); + item0.expanded.set(true); tree.listBehavior.goto(item0); tree.onKeydown(down({control: true})); @@ -854,7 +858,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); - item0.expansion.open(); + item0.expanded.set(true); tree.listBehavior.goto(item1); tree.onKeydown(shift()); @@ -866,7 +870,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); - item0.expansion.open(); + item0.expanded.set(true); tree.listBehavior.goto(item0_0); tree.onKeydown(shift()); @@ -884,9 +888,9 @@ describe('Tree Pattern', () => { it('should not select disabled items on navigation', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false, selectable: true}, - {value: 'B', disabled: true, selectable: true}, - {value: 'C', disabled: false, selectable: true}, + {value: 'A', disabled: false, selectable: true, expanded: false}, + {value: 'B', disabled: true, selectable: true, expanded: false}, + {value: 'C', disabled: false, selectable: true, expanded: false}, ]; treeInputs.softDisabled.set(false); const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -899,9 +903,9 @@ describe('Tree Pattern', () => { it('should not select non-selectable items on navigation', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false, selectable: true}, - {value: 'B', disabled: false, selectable: false}, - {value: 'C', disabled: false, selectable: true}, + {value: 'A', disabled: false, selectable: true, expanded: false}, + {value: 'B', disabled: false, selectable: false, expanded: false}, + {value: 'C', disabled: false, selectable: true, expanded: false}, ]; const {tree, allItems} = createTree(localTreeData, treeInputs); treeInputs.values.set(['A']); @@ -916,7 +920,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); - item0.expansion.open(); + item0.expanded.set(true); tree.listBehavior.goto(item0_0); tree.onKeydown(a({control: true})); @@ -1073,7 +1077,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); - item0.expansion.open(); + item0.expanded.set(true); tree.onKeydown(shift()); tree.onPointerdown(createClickEvent(item1.element()!, {shift: true})); @@ -1149,7 +1153,7 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item1 = getItemByValue(allItems(), 'Item 1'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); - item0.expansion.open(); + item0.expanded.set(true); tree.onKeydown(shift()); tree.onPointerdown(createClickEvent(item1.element()!, {shift: true})); @@ -1161,7 +1165,7 @@ describe('Tree Pattern', () => { it('should not select disabled items on click', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: true, selectable: true}, + {value: 'A', disabled: true, selectable: true, expanded: false}, ]; const {tree, allItems} = createTree(localTreeData, treeInputs); const itemA = getItemByValue(allItems(), 'A'); @@ -1173,7 +1177,7 @@ describe('Tree Pattern', () => { it('should not select non-selectable items on click', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false, selectable: false}, + {value: 'A', disabled: false, selectable: false, expanded: false}, ]; const {tree, allItems} = createTree(localTreeData, treeInputs); const itemA = getItemByValue(allItems(), 'A'); @@ -1214,18 +1218,23 @@ describe('Tree Pattern', () => { children: [ { value: 'Item 0-0', - children: [{value: 'Item 0-0-0', disabled: false, selectable: true}], + children: [ + {value: 'Item 0-0-0', disabled: false, selectable: true, expanded: false}, + ], disabled: false, selectable: true, + expanded: false, }, { value: 'Item 0-1', disabled: false, selectable: true, + expanded: false, }, ], disabled: false, selectable: true, + expanded: false, }, ], treeInputs, @@ -1236,13 +1245,13 @@ describe('Tree Pattern', () => { expect(item0_0.visible()).toBe(false); expect(item0_0_0.visible()).toBe(false); - item0.expansion.open(); + item0.expanded.set(true); expect(item0_0.visible()).toBe(true); expect(item0_0_0.visible()).toBe(false); - item0_0.expansion.open(); + item0_0.expanded.set(true); expect(item0_0.visible()).toBe(true); expect(item0_0_0.visible()).toBe(true); - item0.expansion.close(); + item0.expanded.set(false); expect(item0_0.visible()).toBe(false); expect(item0_0_0.visible()).toBe(false); }); @@ -1252,7 +1261,7 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); expect(item0.expanded()).toBe(false); - item0.expansion.open(); + item0.expanded.set(true); expect(item0.expanded()).toBe(true); }); @@ -1273,7 +1282,7 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); tree.listBehavior.goto(item0); - item0.expansion.open(); + item0.expanded.set(true); tree.onKeydown(right()); expect(tree.activeItem()).toBe(item0_0); @@ -1294,7 +1303,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); tree.listBehavior.goto(item0); - item0.expansion.open(); + item0.expanded.set(true); expect(item0.expanded()).toBe(true); tree.onKeydown(left()); @@ -1306,7 +1315,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); - item0.expansion.open(); + item0.expanded.set(true); tree.listBehavior.goto(item0_0); tree.onKeydown(left()); @@ -1387,7 +1396,7 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); tree.listBehavior.goto(item0); - item0.expansion.open(); + item0.expanded.set(true); tree.onKeydown(right()); expect(tree.activeItem()).toBe(item0_0); @@ -1399,7 +1408,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); - item0.expansion.open(); + item0.expanded.set(true); tree.listBehavior.goto(item0_0); tree.onKeydown(left()); @@ -1420,7 +1429,7 @@ describe('Tree Pattern', () => { const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); tree.listBehavior.goto(item0); - item0.expansion.open(); + item0.expanded.set(true); tree.inputs.values.set(['Item 1']); // pre-select something else tree.onKeydown(right({control: true})); @@ -1433,7 +1442,7 @@ describe('Tree Pattern', () => { const {tree, allItems} = createTree(treeExample, treeInputs); const item0 = getItemByValue(allItems(), 'Item 0'); const item0_0 = getItemByValue(allItems(), 'Item 0-0'); - item0.expansion.open(); + item0.expanded.set(true); tree.listBehavior.goto(item0_0); tree.inputs.values.set(['Item 1']); // pre-select something else @@ -1469,8 +1478,8 @@ describe('Tree Pattern', () => { it('should set activeIndex to the first visible focusable item if no selection', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false, selectable: true}, - {value: 'B', disabled: false, selectable: true}, + {value: 'A', disabled: false, selectable: true, expanded: false}, + {value: 'B', disabled: false, selectable: true, expanded: false}, ]; const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -1480,8 +1489,8 @@ describe('Tree Pattern', () => { it('should set activeIndex to the first visible focusable disabled item if softDisabled is true and no selection', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: true, selectable: true}, - {value: 'B', disabled: false, selectable: true}, + {value: 'A', disabled: true, selectable: true, expanded: false}, + {value: 'B', disabled: false, selectable: true, expanded: false}, ]; treeInputs.softDisabled.set(true); const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -1492,9 +1501,9 @@ describe('Tree Pattern', () => { it('should set activeIndex to the first selected visible focusable item', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false, selectable: true}, - {value: 'B', disabled: false, selectable: true}, - {value: 'C', disabled: false, selectable: true}, + {value: 'A', disabled: false, selectable: true, expanded: false}, + {value: 'B', disabled: false, selectable: true, expanded: false}, + {value: 'C', disabled: false, selectable: true, expanded: false}, ]; treeInputs.values.set(['B']); const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -1505,9 +1514,9 @@ describe('Tree Pattern', () => { it('should prioritize the first selected item in visible order', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false, selectable: true}, - {value: 'B', disabled: false, selectable: true}, - {value: 'C', disabled: false, selectable: true}, + {value: 'A', disabled: false, selectable: true, expanded: false}, + {value: 'B', disabled: false, selectable: true, expanded: false}, + {value: 'C', disabled: false, selectable: true, expanded: false}, ]; treeInputs.values.set(['C', 'A']); const {tree, allItems} = createTree(localTreeData, treeInputs); @@ -1518,9 +1527,9 @@ describe('Tree Pattern', () => { it('should skip a selected disabled item if softDisabled is false', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false, selectable: true}, - {value: 'B', disabled: true, selectable: true}, - {value: 'C', disabled: false, selectable: true}, + {value: 'A', disabled: false, selectable: true, expanded: false}, + {value: 'B', disabled: true, selectable: true, expanded: false}, + {value: 'C', disabled: false, selectable: true, expanded: false}, ]; treeInputs.values.set(['B']); treeInputs.softDisabled.set(false); @@ -1532,9 +1541,9 @@ describe('Tree Pattern', () => { it('should select a selected disabled item if softDisabled is true', () => { const localTreeData: TestTreeItem[] = [ - {value: 'A', disabled: false, selectable: true}, - {value: 'B', disabled: true, selectable: true}, - {value: 'C', disabled: false, selectable: true}, + {value: 'A', disabled: false, selectable: true, expanded: false}, + {value: 'B', disabled: true, selectable: true, expanded: false}, + {value: 'C', disabled: false, selectable: true, expanded: false}, ]; treeInputs.values.set(['B']); treeInputs.softDisabled.set(true); diff --git a/src/aria/private/tree/tree.ts b/src/aria/private/tree/tree.ts index b51d784994b1..b9748c327234 100644 --- a/src/aria/private/tree/tree.ts +++ b/src/aria/private/tree/tree.ts @@ -6,14 +6,16 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, signal} from '@angular/core'; +import {computed} from '@angular/core'; import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; import {List, ListInputs, ListItem} from '../behaviors/list/list'; -import {ExpansionItem, ExpansionControl, ListExpansion} from '../behaviors/expansion/expansion'; +import {ExpansionItem, ListExpansion} from '../behaviors/expansion/expansion'; import {KeyboardEventManager, PointerEventManager, Modifier} from '../behaviors/event-manager'; /** Represents the required inputs for a tree item. */ -export interface TreeItemInputs extends Omit, 'index'> { +export interface TreeItemInputs + extends Omit, 'index'>, + Omit { /** The parent item. */ parent: SignalLike | TreePattern>; @@ -32,53 +34,47 @@ export interface TreeItemInputs extends Omit, 'index'> { */ export class TreeItemPattern implements ListItem, ExpansionItem { /** A unique identifier for this item. */ - readonly id: SignalLike; + readonly id: SignalLike = () => this.inputs.id(); /** The value of this item. */ - readonly value: SignalLike; + readonly value: SignalLike = () => this.inputs.value(); /** A reference to the item element. */ - readonly element: SignalLike; + readonly element: SignalLike = () => this.inputs.element()!; /** Whether the item is disabled. */ - readonly disabled: SignalLike; + readonly disabled: SignalLike = () => this.inputs.disabled(); /** The text used by the typeahead search. */ - readonly searchTerm: SignalLike; + readonly searchTerm: SignalLike = () => this.inputs.searchTerm(); /** The tree pattern this item belongs to. */ - readonly tree: SignalLike>; + readonly tree: SignalLike> = () => this.inputs.tree(); /** The parent item. */ - readonly parent: SignalLike | TreePattern>; + readonly parent: SignalLike | TreePattern> = () => this.inputs.parent(); /** The children items. */ - readonly children: SignalLike[]>; + readonly children: SignalLike[]> = () => this.inputs.children(); /** The position of this item among its siblings. */ readonly index = computed(() => this.tree().visibleItems().indexOf(this)); - /** The unique identifier used by the expansion behavior. */ - readonly expansionId: SignalLike; - /** Controls expansion for child items. */ - readonly expansionManager: ListExpansion; - - /** Controls expansion for this item. */ - readonly expansion: ExpansionControl; + readonly expansionBehavior: ListExpansion; /** Whether the item is expandable. It's expandable if children item exist. */ - readonly expandable: SignalLike; + readonly expandable: SignalLike = () => this.inputs.hasChildren(); /** Whether the item is selectable. */ - readonly selectable: SignalLike; + readonly selectable: SignalLike = () => this.inputs.selectable(); + + /** Whether the item is expanded. */ + readonly expanded: WritableSignalLike; /** The level of the current item in a tree. */ readonly level: SignalLike = computed(() => this.parent().level() + 1); - /** Whether this item is currently expanded. */ - readonly expanded = computed(() => this.expansion.isExpanded()); - /** Whether this item is visible. */ readonly visible: SignalLike = computed( () => this.parent().expanded() && this.parent().visible(), @@ -119,28 +115,10 @@ export class TreeItemPattern implements ListItem, ExpansionItem { }); constructor(readonly inputs: TreeItemInputs) { - this.id = inputs.id; - this.value = inputs.value; - this.element = inputs.element; - this.disabled = inputs.disabled; - this.searchTerm = inputs.searchTerm; - this.expansionId = inputs.id; - this.tree = inputs.tree; - this.parent = inputs.parent; - this.children = inputs.children; - this.expandable = inputs.hasChildren; - this.selectable = inputs.selectable; - this.expansion = new ExpansionControl({ - ...inputs, - expandable: this.expandable, - expansionId: this.expansionId, - expansionManager: this.parent().expansionManager, - }); - this.expansionManager = new ListExpansion({ + this.expanded = inputs.expanded; + this.expansionBehavior = new ListExpansion({ ...inputs, multiExpandable: () => true, - // TODO(ok7sai): allow pre-expanded tree items. - expandedIds: signal([]), items: this.children, disabled: computed(() => this.tree()?.disabled() ?? false), }); @@ -170,14 +148,13 @@ export interface TreeInputs extends Omit, V>, ' currentType: SignalLike<'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'>; } -export interface TreePattern extends TreeInputs {} /** Controls the state and interactions of a tree view. */ -export class TreePattern { +export class TreePattern implements TreeInputs { /** The list behavior for the tree. */ readonly listBehavior: List, V>; /** Controls expansion for direct children of the tree root (top-level items). */ - readonly expansionManager: ListExpansion; + readonly expansionBehavior: ListExpansion; /** The root level is 0. */ readonly level = () => 0; @@ -337,62 +314,57 @@ export class TreePattern { }); /** A unique identifier for the tree. */ - id: SignalLike; + readonly id: SignalLike = () => this.inputs.id(); + + /** The host native element. */ + readonly element: SignalLike = () => this.inputs.element()!; /** Whether the tree is in navigation mode. */ - nav: SignalLike; + readonly nav: SignalLike = () => this.inputs.nav(); /** The aria-current type. */ - currentType: SignalLike<'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'>; + readonly currentType: SignalLike< + 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false' + > = () => this.inputs.currentType(); /** All items in the tree, in document order (DFS-like, a flattened list). */ - allItems: SignalLike[]>; + readonly allItems: SignalLike[]> = () => this.inputs.allItems(); + + /** The focus strategy used by the tree. */ + readonly focusMode: SignalLike<'roving' | 'activedescendant'> = () => this.inputs.focusMode(); /** Whether the tree is disabled. */ - disabled: SignalLike; + readonly disabled: SignalLike = () => this.inputs.disabled(); /** The currently active item in the tree. */ - activeItem: WritableSignalLike | undefined> = signal(undefined); + readonly activeItem: WritableSignalLike | undefined>; /** Whether disabled items should be focusable. */ - softDisabled: SignalLike; + readonly softDisabled: SignalLike = () => this.inputs.softDisabled(); /** Whether the focus should wrap when navigating past the first or last item. */ - wrap: SignalLike; + readonly wrap: SignalLike = () => this.inputs.wrap(); /** The orientation of the tree. */ - orientation: SignalLike<'vertical' | 'horizontal'>; + readonly orientation: SignalLike<'vertical' | 'horizontal'> = () => this.inputs.orientation(); /** The text direction of the tree. */ - textDirection: SignalLike<'ltr' | 'rtl'>; + readonly textDirection: SignalLike<'ltr' | 'rtl'> = () => this.textDirection(); /** Whether multiple items can be selected at the same time. */ - multi: SignalLike; + readonly multi: SignalLike = computed(() => (this.nav() ? false : this.inputs.multi())); /** The selection mode of the tree. */ - selectionMode: SignalLike<'follow' | 'explicit'>; + readonly selectionMode: SignalLike<'follow' | 'explicit'> = () => this.inputs.selectionMode(); /** The delay in milliseconds to wait before clearing the typeahead buffer. */ - typeaheadDelay: SignalLike; + readonly typeaheadDelay: SignalLike = () => this.inputs.typeaheadDelay(); /** The current selected items of the tree. */ - values: WritableSignalLike; + readonly values: WritableSignalLike; constructor(readonly inputs: TreeInputs) { - this.id = inputs.id; - this.nav = inputs.nav; - this.currentType = inputs.currentType; - this.allItems = inputs.allItems; - this.focusMode = inputs.focusMode; - this.disabled = inputs.disabled; this.activeItem = inputs.activeItem; - this.softDisabled = inputs.softDisabled; - this.wrap = inputs.wrap; - this.orientation = inputs.orientation; - this.textDirection = inputs.textDirection; - this.multi = computed(() => (this.nav() ? false : this.inputs.multi())); - this.selectionMode = inputs.selectionMode; - this.typeaheadDelay = inputs.typeaheadDelay; this.values = inputs.values; this.listBehavior = new List({ @@ -401,10 +373,8 @@ export class TreePattern { multi: this.multi, }); - this.expansionManager = new ListExpansion({ + this.expansionBehavior = new ListExpansion({ multiExpandable: () => true, - // TODO(ok7sai): allow pre-expanded tree items. - expandedIds: signal([]), items: this.children, disabled: this.disabled, }); @@ -470,7 +440,7 @@ export class TreePattern { if (item.expanded()) { this.collapse(); } else { - item.expansion.open(); + this.expansionBehavior.open(item); } } @@ -480,7 +450,7 @@ export class TreePattern { if (!item || !this.listBehavior.isFocusable(item)) return; if (item.expandable() && !item.expanded()) { - item.expansion.open(); + this.expansionBehavior.open(item); } else if ( item.expanded() && item.children().some(item => this.listBehavior.isFocusable(item)) @@ -493,7 +463,7 @@ export class TreePattern { expandSiblings(item?: TreeItemPattern) { item ??= this.activeItem(); const siblings = item?.parent()?.children(); - siblings?.forEach(item => item.expansion.open()); + siblings?.forEach(item => this.expansionBehavior.open(item)); } /** Collapses a tree item. */ @@ -502,7 +472,7 @@ export class TreePattern { if (!item || !this.listBehavior.isFocusable(item)) return; if (item.expandable() && item.expanded()) { - item.expansion.close(); + this.expansionBehavior.close(item); } else if (item.parent() && item.parent() !== this) { const parentItem = item.parent(); if (parentItem instanceof TreeItemPattern && this.listBehavior.isFocusable(parentItem)) { diff --git a/src/aria/tabs/tabs.ts b/src/aria/tabs/tabs.ts index 234afcb2e200..cc2808873175 100644 --- a/src/aria/tabs/tabs.ts +++ b/src/aria/tabs/tabs.ts @@ -16,7 +16,6 @@ import { inject, input, model, - linkedSignal, signal, Signal, afterRenderEffect, @@ -88,10 +87,10 @@ export class Tabs { private readonly _unorderedPanels = signal(new Set()); /** The Tab UIPattern of the child Tabs. */ - tabs = computed(() => this._tablist()?.tabs()); + readonly _tabPatterns = computed(() => this._tablist()?._tabPatterns()); /** The TabPanel UIPattern of the child TabPanels. */ - unorderedTabpanels = computed(() => + readonly _unorderedTabpanelPatterns = computed(() => [...this._unorderedPanels()].map(tabpanel => tabpanel._pattern), ); @@ -116,13 +115,6 @@ export class Tabs { this._unorderedPanels.set(new Set(this._unorderedPanels())); } } - - /** Opens the tab panel with the specified value. */ - open(value: string) { - const tab = this.tabs()?.find(t => t.value() === value); - - tab?.expansion.open(); - } } /** @@ -166,16 +158,11 @@ export class TabList implements OnInit, OnDestroy { /** The Tabs nested inside of the TabList. */ private readonly _unorderedTabs = signal(new Set()); - /** The internal tab selection state. */ - private readonly _selection = linkedSignal(() => - this.selectedTab() ? [this.selectedTab()!] : [], - ); - /** Text direction. */ readonly textDirection = inject(Directionality).valueSignal; /** The Tab UIPatterns of the child Tabs. */ - readonly tabs = computed(() => + readonly _tabPatterns = computed(() => [...this._unorderedTabs()].sort(sortDirectives).map(tab => tab._pattern), ); @@ -205,17 +192,16 @@ export class TabList implements OnInit, OnDestroy { */ readonly selectionMode = input<'follow' | 'explicit'>('follow'); + /** The current selected tab. */ + readonly selectedTab = model(); + /** Whether the tablist is disabled. */ readonly disabled = input(false, {transform: booleanAttribute}); - /** The currently selected tab. */ - readonly selectedTab = model(); - /** The TabList UIPattern. */ readonly _pattern: TabListPattern = new TabListPattern({ ...this, - items: this.tabs, - values: this._selection, + items: this._tabPatterns, activeItem: signal(undefined), element: () => this._elementRef.nativeElement, }); @@ -224,13 +210,25 @@ export class TabList implements OnInit, OnDestroy { private _hasFocused = signal(false); constructor() { - afterRenderEffect(() => this.selectedTab.set(this._selection()[0])); - afterRenderEffect(() => { if (!this._hasFocused()) { this._pattern.setDefaultState(); } }); + + afterRenderEffect(() => { + const tab = this._pattern.selectedTab(); + if (tab) { + this.selectedTab.set(tab.value()); + } + }); + + afterRenderEffect(() => { + const value = this.selectedTab(); + if (value) { + this._pattern.open(value); + } + }); } onFocus() { @@ -254,6 +252,11 @@ export class TabList implements OnInit, OnDestroy { this._unorderedTabs().delete(child); this._unorderedTabs.set(new Set(this._unorderedTabs())); } + + /** Opens the tab panel with the specified value. */ + open(value: string): boolean { + return this._pattern.open(value); + } } /** @@ -294,8 +297,8 @@ export class Tab implements HasElement, OnInit, OnDestroy { /** The parent TabList. */ private readonly _tabList = inject(TabList); - /** A global unique identifier for the tab. */ - private readonly _id = inject(_IdGenerator).getId('ng-tab-'); + /** A unique identifier for the widget. */ + readonly id = input(inject(_IdGenerator).getId('ng-tab-', true)); /** The host native element. */ readonly element = computed(() => this._elementRef.nativeElement); @@ -305,36 +308,32 @@ export class Tab implements HasElement, OnInit, OnDestroy { /** The TabPanel UIPattern associated with the tab */ readonly tabpanel = computed(() => - this._tabs.unorderedTabpanels().find(tabpanel => tabpanel.value() === this.value()), + this._tabs._unorderedTabpanelPatterns().find(tabpanel => tabpanel.value() === this.value()), ); /** Whether a tab is disabled. */ readonly disabled = input(false, {transform: booleanAttribute}); - /** A local unique identifier for the tab. */ + /** The remote tabpanel unique identifier. */ readonly value = input.required(); /** Whether the tab is active. */ readonly active = computed(() => this._pattern.active()); - /** Whether the tab is expanded. */ - readonly expanded = computed(() => this._pattern.expanded()); - /** Whether the tab is selected. */ readonly selected = computed(() => this._pattern.selected()); /** The Tab UIPattern. */ readonly _pattern: TabPattern = new TabPattern({ ...this, - id: () => this._id, tablist: this.tablist, tabpanel: this.tabpanel, - value: this.value, + expanded: signal(false), }); /** Opens this tab panel. */ open() { - this._pattern.expansion.open(); + this._pattern.open(); } ngOnInit() { @@ -392,7 +391,9 @@ export class TabPanel implements OnInit, OnDestroy { private readonly _id = inject(_IdGenerator).getId('ng-tabpanel-', true); /** The Tab UIPattern associated with the tabpanel */ - readonly tab = computed(() => this._Tabs.tabs()?.find(tab => tab.value() === this.value())); + readonly tab = computed(() => + this._Tabs._tabPatterns()?.find(tab => tab.value() === this.value()), + ); /** A local unique identifier for the tabpanel. */ readonly value = input.required(); diff --git a/src/aria/tree/tree.spec.ts b/src/aria/tree/tree.spec.ts index 9ab2c1d6daf0..c64209561fc5 100644 --- a/src/aria/tree/tree.spec.ts +++ b/src/aria/tree/tree.spec.ts @@ -1037,6 +1037,36 @@ describe('Tree', () => { }); describe('expansion and collapse', () => { + it('should expand items by setting expanded input', () => { + setupTestTree(); + updateTree({ + nodes: [ + { + value: 'fruits', + label: 'Fruits', + children: [ + {value: 'apple', label: 'Apple'}, + {value: 'banana', label: 'Banana'}, + { + value: 'berries', + label: 'Berries', + children: [ + {value: 'strawberry', label: 'Strawberry'}, + {value: 'blueberry', label: 'Blueberry'}, + ], + expanded: true, + }, + ], + expanded: true, + }, + ], + }); + const fruitsEl = getTreeItemElementByValue('fruits')!; + const berriesEl = getTreeItemElementByValue('berries')!; + expect(fruitsEl.getAttribute('aria-expanded')).toBe('true'); + expect(berriesEl.getAttribute('aria-expanded')).toBe('true'); + }); + describe('LTR', () => { beforeEach(() => { setupTestTree(); @@ -1451,6 +1481,7 @@ interface TestTreeNode { label: string; disabled?: boolean; selectable?: boolean; + expanded?: boolean; children?: TestTreeNode[]; } @@ -1482,6 +1513,7 @@ interface TestTreeNode { [label]="node.label" [disabled]="!!node.disabled" [selectable]="node.selectable ?? true" + [expanded]="node.expanded ?? false" [parent]="parent" [attr.data-value]="node.value" #treeItem="ngTreeItem" diff --git a/src/aria/tree/tree.ts b/src/aria/tree/tree.ts index df6a6bd1378f..0867d191355b 100644 --- a/src/aria/tree/tree.ts +++ b/src/aria/tree/tree.ts @@ -99,13 +99,6 @@ function sortDirectives(a: HasElement, b: HasElement) { hostDirectives: [ComboboxPopup], }) export class Tree { - /** A unique identifier for the tree. */ - private readonly _generatedId = inject(_IdGenerator).getId('ng-tree-', true); - - // TODO(wagnermaciel): https://github.com/angular/components/pull/30495#discussion_r1972601144. - /** A unique identifier for the tree. */ - protected id = computed(() => this._generatedId); - /** A reference to the parent combobox popup, if one exists. */ private readonly _popup = inject>(ComboboxPopup, { optional: true, @@ -117,6 +110,12 @@ export class Tree { /** All TreeItem instances within this tree. */ private readonly _unorderedItems = signal(new Set>()); + /** A unique identifier for the tree. */ + readonly id = input(inject(_IdGenerator).getId('ng-tree-', true)); + + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); + /** Orientation of the tree. */ readonly orientation = input<'vertical' | 'horizontal'>('vertical'); @@ -183,7 +182,6 @@ export class Tree { [...this._unorderedItems()].sort(sortDirectives).map(item => item._pattern), ), activeItem: signal | undefined>(undefined), - element: () => this._elementRef.nativeElement, combobox: () => this._popup?.combobox?._pattern, }; @@ -262,7 +260,7 @@ export class Tree { '[attr.data-active]': 'active()', 'role': 'treeitem', '[id]': '_pattern.id()', - '[attr.aria-expanded]': 'expanded()', + '[attr.aria-expanded]': '_expanded()', '[attr.aria-selected]': 'selected()', '[attr.aria-current]': '_pattern.current()', '[attr.aria-disabled]': '_pattern.disabled()', @@ -276,12 +274,12 @@ export class TreeItem extends DeferredContentAware implements OnInit, OnDestr /** A reference to the tree item element. */ private readonly _elementRef = inject(ElementRef); - /** A unique identifier for the tree item. */ - private readonly _id = inject(_IdGenerator).getId('ng-tree-item-', true); - /** The owned tree item group. */ private readonly _group = signal | undefined>(undefined); + /** A unique identifier for the tree item. */ + readonly id = input(inject(_IdGenerator).getId('ng-tree-item-', true)); + /** The host native element. */ readonly element = computed(() => this._elementRef.nativeElement); @@ -297,6 +295,9 @@ export class TreeItem extends DeferredContentAware implements OnInit, OnDestr /** Whether the tree item is selectable. */ readonly selectable = input(true); + /** Whether the tree item is expanded. */ + readonly expanded = model(false); + /** Optional label for typeahead. Defaults to the element's textContent. */ readonly label = input(); @@ -314,11 +315,6 @@ export class TreeItem extends DeferredContentAware implements OnInit, OnDestr /** Whether the item is active. */ readonly active = computed(() => this._pattern.active()); - /** Whether this item is currently expanded, returning null if not expandable. */ - readonly expanded = computed(() => - this._pattern.expandable() ? this._pattern.expanded() : null, - ); - /** The level of the current item in a tree. */ readonly level = computed(() => this._pattern.level()); @@ -328,6 +324,11 @@ export class TreeItem extends DeferredContentAware implements OnInit, OnDestr /** Whether this item is visible due to all of its parents being expanded. */ readonly visible = computed(() => this._pattern.visible()); + /** Whether the tree is expanded. Use this value for aria-expanded. */ + protected readonly _expanded: Signal = computed(() => + this._pattern.expandable() ? this._pattern.expanded() : undefined, + ); + /** The UI pattern for this item. */ _pattern: TreeItemPattern; @@ -359,7 +360,6 @@ export class TreeItem extends DeferredContentAware implements OnInit, OnDestr }); this._pattern = new TreeItemPattern({ ...this, - id: () => this._id, tree: treePattern, parent: parentPattern, children: computed(() => this._group()?.children() ?? []), 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 489b62ed9a82..212a917e6252 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 @@ -3,15 +3,6 @@ Multi Disabled Soft Disabled - - - Expanded Items - - @for (item of items; track item) { - {{item}} - } - -

@@ -40,7 +30,7 @@

- 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 1935a62a57f7..039c82777309 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, computed, model, Signal} from '@angular/core'; +import {Component, computed, Signal, viewChildren} from '@angular/core'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; import {MatCheckboxModule} from '@angular/material/checkbox'; import {MatFormFieldModule} from '@angular/material/form-field'; @@ -36,7 +36,13 @@ export class AccordionConfigurableExample { multi = new FormControl(true, {nonNullable: true}); disabled = new FormControl(false, {nonNullable: true}); softDisabled = new FormControl(true, {nonNullable: true}); - expandedIds = model(['item1']); + + triggers = viewChildren(AccordionTrigger); + expandedIds = computed(() => + this.triggers() + .filter(t => t.expanded()) + .map(t => t.panelId()), + ); // Example items items = ['item1', 'item2', 'item3', 'item4', 'item5']; diff --git a/src/components-examples/aria/accordion/accordion-disabled-focusable/accordion-disabled-focusable-example.html b/src/components-examples/aria/accordion/accordion-disabled-focusable/accordion-disabled-focusable-example.html index 20ba96f33719..abc529f832c8 100644 --- a/src/components-examples/aria/accordion/accordion-disabled-focusable/accordion-disabled-focusable-example.html +++ b/src/components-examples/aria/accordion/accordion-disabled-focusable/accordion-disabled-focusable-example.html @@ -3,7 +3,6 @@ class="example-accordion-group" [multiExpandable]="true" [softDisabled]="true" - [(expandedPanels)]="expandedIds" >

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 f695846b5237..2194152013e0 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 @@ -1,4 +1,4 @@ -import {Component, computed, model, Signal} from '@angular/core'; +import {Component, computed, Signal, viewChildren} from '@angular/core'; import {MatIconModule} from '@angular/material/icon'; import { AccordionGroup, @@ -15,7 +15,12 @@ import { imports: [MatIconModule, AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent], }) export class AccordionDisabledFocusableExample { - expandedIds = model([]); + triggers = viewChildren(AccordionTrigger); + expandedIds = computed(() => + this.triggers() + .filter(t => t.expanded()) + .map(t => t.panelId()), + ); expansionIcon(item: string): Signal { return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); diff --git a/src/components-examples/aria/accordion/accordion-disabled-skipped/accordion-disabled-skipped-example.html b/src/components-examples/aria/accordion/accordion-disabled-skipped/accordion-disabled-skipped-example.html index cc4e6ea61497..3b4a6bdd8f0f 100644 --- a/src/components-examples/aria/accordion/accordion-disabled-skipped/accordion-disabled-skipped-example.html +++ b/src/components-examples/aria/accordion/accordion-disabled-skipped/accordion-disabled-skipped-example.html @@ -3,7 +3,6 @@ class="example-accordion-group" [multiExpandable]="true" [softDisabled]="false" - [(expandedPanels)]="expandedIds" >

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 7eb682a25c9e..696db21568d1 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 @@ -1,4 +1,4 @@ -import {Component, computed, model, Signal} from '@angular/core'; +import {Component, computed, Signal, viewChildren} from '@angular/core'; import {MatIconModule} from '@angular/material/icon'; import { AccordionGroup, @@ -15,7 +15,12 @@ import { imports: [MatIconModule, AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent], }) export class AccordionDisabledSkippedExample { - expandedIds = model([]); + triggers = viewChildren(AccordionTrigger); + expandedIds = computed(() => + this.triggers() + .filter(t => t.expanded()) + .map(t => t.panelId()), + ); expansionIcon(item: string): Signal { return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); diff --git a/src/components-examples/aria/accordion/accordion-disabled/accordion-disabled-example.html b/src/components-examples/aria/accordion/accordion-disabled/accordion-disabled-example.html index e70b472420a1..572a4d5ca697 100644 --- a/src/components-examples/aria/accordion/accordion-disabled/accordion-disabled-example.html +++ b/src/components-examples/aria/accordion/accordion-disabled/accordion-disabled-example.html @@ -1,7 +1,7 @@ -
+

- 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 f00ed2704457..d668c9be4e43 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 @@ -1,4 +1,4 @@ -import {Component, computed, model, Signal} from '@angular/core'; +import {Component, computed, Signal, viewChildren} from '@angular/core'; import {MatIconModule} from '@angular/material/icon'; import { AccordionGroup, @@ -15,7 +15,12 @@ import { imports: [MatIconModule, AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent], }) export class AccordionDisabledExample { - expandedIds = model(['item1']); + triggers = viewChildren(AccordionTrigger); + expandedIds = computed(() => + this.triggers() + .filter(t => t.expanded()) + .map(t => t.panelId()), + ); expansionIcon(item: string): Signal { return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); diff --git a/src/components-examples/aria/accordion/accordion-multi-expansion/accordion-multi-expansion-example.html b/src/components-examples/aria/accordion/accordion-multi-expansion/accordion-multi-expansion-example.html index a0056042b9dc..b657bce20ff5 100644 --- a/src/components-examples/aria/accordion/accordion-multi-expansion/accordion-multi-expansion-example.html +++ b/src/components-examples/aria/accordion/accordion-multi-expansion/accordion-multi-expansion-example.html @@ -2,7 +2,6 @@ ngAccordionGroup class="example-accordion-group" [multiExpandable]="true" - [(expandedPanels)]="expandedIds" >

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 910a4bef104c..41d0b82cde32 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 @@ -1,4 +1,4 @@ -import {Component, computed, model, Signal} from '@angular/core'; +import {Component, computed, Signal, viewChildren} from '@angular/core'; import {MatIconModule} from '@angular/material/icon'; import { AccordionGroup, @@ -15,7 +15,12 @@ import { imports: [MatIconModule, AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent], }) export class AccordionMultiExpansionExample { - expandedIds = model([]); + triggers = viewChildren(AccordionTrigger); + expandedIds = computed(() => + this.triggers() + .filter(t => t.expanded()) + .map(t => t.panelId()), + ); expansionIcon(item: string): Signal { return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); diff --git a/src/components-examples/aria/accordion/accordion-single-expansion/accordion-single-expansion-example.html b/src/components-examples/aria/accordion/accordion-single-expansion/accordion-single-expansion-example.html index 16e3129ae9da..fca03e697d10 100644 --- a/src/components-examples/aria/accordion/accordion-single-expansion/accordion-single-expansion-example.html +++ b/src/components-examples/aria/accordion/accordion-single-expansion/accordion-single-expansion-example.html @@ -2,7 +2,6 @@ ngAccordionGroup class="example-accordion-group" [multiExpandable]="false" - [(expandedPanels)]="expandedIds" >

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 7804ed8a4baf..a9558c406eb3 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 @@ -1,4 +1,4 @@ -import {Component, computed, model, Signal} from '@angular/core'; +import {Component, computed, Signal, viewChildren} from '@angular/core'; import {MatIconModule} from '@angular/material/icon'; import { AccordionGroup, @@ -15,7 +15,12 @@ import { imports: [MatIconModule, AccordionGroup, AccordionTrigger, AccordionPanel, AccordionContent], }) export class AccordionSingleExpansionExample { - expandedIds = model([]); + triggers = viewChildren(AccordionTrigger); + expandedIds = computed(() => + this.triggers() + .filter(t => t.expanded()) + .map(t => t.panelId()), + ); expansionIcon(item: string): Signal { return computed(() => (this.expandedIds().includes(item) ? 'expand_less' : 'expand_more')); diff --git a/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html b/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html index ab9291f8282b..427b2f366ac5 100644 --- a/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html +++ b/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html @@ -47,8 +47,8 @@ [softDisabled]="softDisabled.value" [orientation]="orientation" [focusMode]="focusMode" - [selectionMode]="selectionMode" [(selectedTab)]="tabSelection" + [selectionMode]="selectionMode" >
  • tab 1
  • tab 2
  • diff --git a/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.html b/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.html index 6c69e1d210b3..9fbccf4943c4 100644 --- a/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.html +++ b/src/components-examples/aria/tree/tree-configurable/tree-configurable-example.html @@ -96,6 +96,7 @@ [value]="node.value" [label]="node.name" [disabled]="node.disabled" + [(expanded)]="node.expanded" #treeItem="ngTreeItem" class="example-tree-item example-selectable example-stateful" > diff --git a/src/components-examples/aria/tree/tree-data.ts b/src/components-examples/aria/tree/tree-data.ts index 8436794a43b8..a515bda22e41 100644 --- a/src/components-examples/aria/tree/tree-data.ts +++ b/src/components-examples/aria/tree/tree-data.ts @@ -10,6 +10,7 @@ export type TreeNode = { value: string; children?: TreeNode[]; disabled?: boolean; + expanded?: boolean; }; export const NODES: TreeNode[] = [ @@ -21,6 +22,7 @@ export const NODES: TreeNode[] = [ {name: 'favicon.ico', value: 'public/favicon.ico'}, {name: 'styles.css', value: 'public/styles.css'}, ], + expanded: false, }, { name: 'src', @@ -34,25 +36,33 @@ export const NODES: TreeNode[] = [ {name: 'app.module.ts', value: 'src/app/app.module.ts', disabled: true}, {name: 'app.css', value: 'src/app/app.css'}, ], + expanded: false, }, { name: 'assets', value: 'src/assets', children: [{name: 'logo.png', value: 'src/assets/logo.png'}], + expanded: false, }, { name: 'environments', value: 'src/environments', children: [ - {name: 'environment.prod.ts', value: 'src/environments/environment.prod.ts'}, + { + name: 'environment.prod.ts', + value: 'src/environments/environment.prod.ts', + expanded: false, + }, {name: 'environment.ts', value: 'src/environments/environment.ts'}, ], + expanded: false, }, {name: 'main.ts', value: 'src/main.ts'}, {name: 'polyfills.ts', value: 'src/polyfills.ts'}, {name: 'styles.css', value: 'src/styles.css', disabled: true}, {name: 'test.ts', value: 'src/test.ts'}, ], + expanded: false, }, {name: 'angular.json', value: 'angular.json'}, {name: 'package.json', value: 'package.json'},