diff --git a/.ng-dev/commit-message.mts b/.ng-dev/commit-message.mts index c868f0e0b1d4..6dc86731d94b 100644 --- a/.ng-dev/commit-message.mts +++ b/.ng-dev/commit-message.mts @@ -11,6 +11,7 @@ export const commitMessage: CommitMessageConfig = { 'multiple', // For when a commit applies to multiple components. 'aria/accordion', 'aria/combobox', + 'aria/disclosure', 'aria/grid', 'aria/listbox', 'aria/menu', diff --git a/docs/src/app/shared/doc-viewer/angular-aria-banner/angular-aria-banner.ts b/docs/src/app/shared/doc-viewer/angular-aria-banner/angular-aria-banner.ts index 4a23a0288986..8b814a8e0d42 100644 --- a/docs/src/app/shared/doc-viewer/angular-aria-banner/angular-aria-banner.ts +++ b/docs/src/app/shared/doc-viewer/angular-aria-banner/angular-aria-banner.ts @@ -17,6 +17,7 @@ const ANGULAR_ARIA_LINKS: Record = { 'tree': 'https://angular.dev/guide/aria/tree', 'accordion': 'https://angular.dev/guide/aria/accordion', 'menu': 'https://angular.dev/guide/aria/menu', + 'disclosure': 'https://angular.dev/guide/aria/disclosure', }; /** diff --git a/guides/aria-disclosure.md b/guides/aria-disclosure.md new file mode 100644 index 000000000000..57850cfd052d --- /dev/null +++ b/guides/aria-disclosure.md @@ -0,0 +1,338 @@ +# Disclosure + +Disclosure ARIA pattern Disclosure API Reference + +## Overview + +A disclosure is a widget that enables content to be either collapsed (hidden) or expanded (visible). It provides a trigger button that controls the visibility of associated content, commonly used for FAQ sections, "read more" interactions, and collapsible panels. + +### app.ts + +```typescript +import {ChangeDetectionStrategy, Component, signal} from '@angular/core'; +import {DisclosureTrigger, DisclosureContent} from '@angular/aria/disclosure'; + +@Component({ + selector: 'app-root', + templateUrl: 'app.html', + styleUrl: 'app.css', + imports: [DisclosureTrigger, DisclosureContent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class App { + devilFruits = [ + { + id: 'gomu', + name: 'Gomu Gomu no Mi', + type: 'Paramecia', + user: 'Monkey D. Luffy', + description: 'Grants the user a body with the properties of rubber, making them immune to blunt attacks and electricity. Awakened as the mythical Hito Hito no Mi, Model: Nika.', + expanded: signal(true), + }, + { + id: 'mera', + name: 'Mera Mera no Mi', + type: 'Logia', + user: 'Sabo (formerly Portgas D. Ace)', + description: 'Allows the user to create, control, and transform into fire at will. One of the most powerful Logia-type Devil Fruits.', + expanded: signal(false), + }, + { + id: 'ope', + name: 'Ope Ope no Mi', + type: 'Paramecia', + user: 'Trafalgar D. Water Law', + description: 'Creates a spherical territory called "ROOM" where the user can manipulate anything within. Known as the "Ultimate Devil Fruit" for its ability to grant eternal youth.', + expanded: signal(false), + }, + ]; +} +``` + +### app.html + +```html +

Devil Fruit Encyclopedia

+
+ @for (fruit of devilFruits; track fruit.id) { +
+ + +
+

Current User: {{ fruit.user }}

+

{{ fruit.description }}

+
+
+ } +
+``` + +### app.css + +```css +.fruit-list { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 600px; + font-family: system-ui, sans-serif; +} + +.fruit-card { + border: 2px solid var(--gray-300, #d1d5db); + border-radius: 12px; + overflow: hidden; + background: var(--white, #ffffff); +} + +.fruit-trigger { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: var(--gray-50, #f9fafb); + border: none; + cursor: pointer; + font-size: 1rem; + text-align: left; + transition: background-color 0.2s ease; +} + +.fruit-trigger:hover { + background: var(--gray-100, #f3f4f6); +} + +.fruit-trigger:focus-visible { + outline: 2px solid var(--vivid-pink, #f542a4); + outline-offset: -2px; +} + +.fruit-icon { + font-size: 0.75rem; + color: var(--gray-500, #6b7280); +} + +.fruit-name { + flex: 1; + font-weight: 600; +} + +.fruit-type { + padding: 4px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.fruit-type[data-type='Paramecia'] { + background: #dbeafe; + color: #1e40af; +} + +.fruit-type[data-type='Logia'] { + background: #fef3c7; + color: #92400e; +} + +.fruit-type[data-type='Zoan'] { + background: #d1fae5; + color: #065f46; +} + +.fruit-details { + padding: 16px; + background: var(--white, #ffffff); + border-top: 1px solid var(--gray-200, #e5e7eb); +} + +.fruit-details p { + margin: 0 0 8px 0; + color: var(--gray-700, #374151); + line-height: 1.6; +} + +.fruit-details p:last-child { + margin-bottom: 0; +} +``` + +## APIs + +### DisclosureTrigger Directive + +The `ngDisclosureTrigger` directive creates a button that toggles the visibility of associated content. + +#### Inputs + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| expanded | boolean | false | Whether the content is expanded | +| disabled | boolean | false | Disables the trigger | +| alwaysExpanded | boolean | false | Keeps content always visible, prevents collapsing | +| controls | string | - | ID of the controlled content element | +| id | string | auto-generated | Unique identifier for the trigger | + +#### Signals + +| Property | Type | Description | +|----------|------|-------------| +| expanded | ModelSignal\ | Two-way bindable expanded state using [(expanded)] | + +#### Methods + +| Method | Parameters | Description | +|--------|------------|-------------| +| expand | none | Expands the content | +| collapse | none | Collapses the content (respects alwaysExpanded) | +| toggle | none | Toggles the expanded state | + +### DisclosureContent Directive + +The `ngDisclosureContent` directive marks an element as the content panel controlled by a trigger. + +#### Inputs + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| trigger | DisclosureTrigger | - | Reference to the controlling trigger | +| id | string | auto-generated | Unique identifier for the content | +| preserveContent | boolean | false | Whether to preserve DOM content when collapsed | + +#### Signals + +| Property | Type | Description | +|----------|------|-------------| +| hidden | Signal\ | Whether the content is currently hidden | +| visible | Signal\ | Whether the content is currently visible | + +### Keyboard Interaction + +| Key | Action | +|-----|--------| +| Enter | Toggles the disclosure | +| Space | Toggles the disclosure | + +### ARIA Attributes + +The directives automatically manage these accessibility attributes: + +**Trigger element:** +- `role="button"` - Identifies as interactive button +- `aria-expanded` - `true` when expanded, `false` when collapsed +- `aria-controls` - References the content element's ID +- `aria-disabled` - `true` when disabled +- `tabindex` - `0` when enabled, `-1` when disabled + +**Content element:** +- `id` - Unique identifier referenced by aria-controls +- `hidden` - Present when collapsed (removed when expanded) + +## Deferred Content + +For performance optimization, combine with `ngDeferredContent` to delay rendering until first expansion: + +```html + + +
+ + + + +
+``` + +Use `preserveContent="true"` to keep content in the DOM after collapsing: + +```html +
+ + + + +
+``` + +## When to use Disclosure vs Accordion + +### Key Differences + +| Feature | Disclosure | Accordion | +|---------|------------|-----------| +| **Grouping** | Independent items | Grouped with `ngAccordionGroup` | +| **Keyboard navigation** | Enter/Space only | Arrow keys, Home/End between items | +| **Expansion mode** | Always independent | Configurable (`multiExpandable`) | +| **ARIA pattern** | Simple button + content | Full accordion with regions | +| **Focus management** | None | Roving tabindex | + +### Use Disclosure when: + +| Scenario | Example | +|----------|---------| +| **Simple show/hide** | "Read more" button, help tooltips | +| **Single expandable item** | One collapsible section | +| **No keyboard nav needed** | Users won't navigate between items with arrow keys | +| **Lightweight interaction** | Minimal ARIA overhead | + +```html + + +
...
+``` + +### Use Accordion when: + +| Scenario | Example | +|----------|---------| +| **Grouped related content** | FAQ sections, settings categories | +| **Keyboard navigation needed** | Users navigate between items with arrow keys | +| **Single expansion mode** | Set `[multiExpandable]="false"` for one-at-a-time | +| **Complex panel management** | `expandAll()`, `collapseAll()` methods | + +```html + +
+ +
...
+ + +
...
+
+``` + +### Quick decision guide + +``` +Do you need keyboard navigation between items (arrow keys)? +├── YES → Use Accordion +└── NO → Is it a single item or independent items? + ├── Single/Independent → Use Disclosure + └── Grouped with shared control → Use Accordion +``` + +## Related patterns and directives + +- **[Accordion](guide/aria/accordion)** - Grouped panels with keyboard navigation and optional single-expansion mode +- **[Tabs](guide/aria/tabs)** - Content organized into tabbed panels + +Disclosure can combine with: + +- **DeferredContent** - Lazy rendering of content until first expansion diff --git a/src/aria/config.bzl b/src/aria/config.bzl index 291412b5a3fb..a224c108d207 100644 --- a/src/aria/config.bzl +++ b/src/aria/config.bzl @@ -2,6 +2,7 @@ ARIA_ENTRYPOINTS = [ "accordion", "combobox", + "disclosure", "grid", "listbox", "menu", diff --git a/src/aria/disclosure/BUILD.bazel b/src/aria/disclosure/BUILD.bazel new file mode 100644 index 000000000000..de7cbc9b9380 --- /dev/null +++ b/src/aria/disclosure/BUILD.bazel @@ -0,0 +1,57 @@ +load("//tools:defaults.bzl", "extract_api_to_json", "ng_project", "ng_web_test_suite") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "disclosure", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//:node_modules/@angular/core", + "//src/aria/private", + "//src/aria/private/disclosure", + "//src/cdk/a11y", + ], +) + +ng_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "disclosure.spec.ts", + ], + deps = [ + ":disclosure", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/cdk/a11y", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) + +filegroup( + name = "source-files", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), +) + +extract_api_to_json( + name = "json_api", + srcs = [ + ":source-files", + ], + entry_point = ":index.ts", + module_name = "@angular/aria/disclosure", + output_name = "aria-disclosure.json", + private_modules = [""], + repo = "angular/components", +) diff --git a/src/aria/disclosure/disclosure-content.ts b/src/aria/disclosure/disclosure-content.ts new file mode 100644 index 000000000000..2eba07af1f65 --- /dev/null +++ b/src/aria/disclosure/disclosure-content.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {afterRenderEffect, computed, Directive, inject, input} from '@angular/core'; +import {_IdGenerator} from '@angular/cdk/a11y'; +import {DeferredContentAware} from '../private'; +import {DISCLOSURE_TRIGGER} from './disclosure-tokens'; +import type {DisclosureTrigger} from './disclosure-trigger'; + +/** + * The content panel of a disclosure that is conditionally visible. + * + * This directive is a container for the content that is shown or hidden based on the + * trigger's expanded state. The content can be provided using an `ng-template` with the + * `ngDeferredContent` directive so that the content is not rendered until the trigger + * is first expanded. + * + * ```html + * + *
+ * + *
+ * + *

3,000,000,000 Berries - One of the Four Emperors of the Sea!

+ *
+ *
+ *
+ * ``` + * + * @developerPreview 21.0 + * + * @see [Disclosure](guide/aria/disclosure) + */ +@Directive({ + selector: '[ngDisclosureContent]', + exportAs: 'ngDisclosureContent', + hostDirectives: [ + { + directive: DeferredContentAware, + inputs: ['preserveContent'], + }, + ], + host: { + '[attr.id]': 'id()', + '[attr.hidden]': 'hidden() ? true : null', + }, +}) +export class DisclosureContent { + /** The DeferredContentAware host directive. */ + private readonly _deferredContentAware = inject(DeferredContentAware); + + /** The disclosure trigger injected from parent context (optional). */ + private readonly _injectedTrigger = inject(DISCLOSURE_TRIGGER, { + optional: true, + }); + + /** A unique identifier for the content element. */ + readonly id = input(inject(_IdGenerator).getId('ng-disclosure-content-', true)); + + /** Reference to the controlling trigger. Falls back to injected trigger. */ + readonly trigger = input(undefined); + + /** The resolved trigger (explicit input or injected). */ + private readonly _resolvedTrigger = computed(() => this.trigger() ?? this._injectedTrigger); + + /** Whether the content is hidden. */ + readonly hidden = computed(() => !this._resolvedTrigger()?.expanded()); + + /** Whether the content is visible. */ + readonly visible = computed(() => this._resolvedTrigger()?.expanded() ?? false); + + constructor() { + // Connect the content's hidden state to the DeferredContentAware's visibility. + afterRenderEffect(() => { + this._deferredContentAware.contentVisible.set(this.visible()); + }); + } +} diff --git a/src/aria/disclosure/disclosure-tokens.ts b/src/aria/disclosure/disclosure-tokens.ts new file mode 100644 index 000000000000..8096dbb08460 --- /dev/null +++ b/src/aria/disclosure/disclosure-tokens.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {InjectionToken} from '@angular/core'; +import type {DisclosureTrigger} from './disclosure-trigger'; + +/** Token used to expose the disclosure trigger to its content. */ +export const DISCLOSURE_TRIGGER = new InjectionToken('DISCLOSURE_TRIGGER'); diff --git a/src/aria/disclosure/disclosure-trigger.ts b/src/aria/disclosure/disclosure-trigger.ts new file mode 100644 index 000000000000..222a96267995 --- /dev/null +++ b/src/aria/disclosure/disclosure-trigger.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + booleanAttribute, + computed, + Directive, + ElementRef, + inject, + input, + model, +} from '@angular/core'; +import {_IdGenerator} from '@angular/cdk/a11y'; +import {DisclosurePattern} from '../private'; +import {DISCLOSURE_TRIGGER} from './disclosure-tokens'; + +/** + * A trigger that toggles the visibility of its associated disclosure content. + * + * The `ngDisclosureTrigger` directive implements the WAI-ARIA disclosure pattern. It provides + * a button that controls the visibility of associated content. The directive handles keyboard + * interactions (Enter, Space) and manages ARIA attributes for accessibility. + * + * ```html + * + *
+ *

🍈 Gomu Gomu no Mi

+ *

A Paramecia-type Devil Fruit (until kaido) that grants the user's body rubber properties...

+ * + *
+ *

Gear 2nd, Gear 3rd, Gear 4th, Gear 5th - Nika Awakening!

+ *
+ *
+ * ``` + * + * @developerPreview 21.0 + * + * @see [Disclosure](guide/aria/disclosure) + */ +@Directive({ + selector: '[ngDisclosureTrigger]', + exportAs: 'ngDisclosureTrigger', + host: { + 'role': 'button', + '[id]': '_pattern.id()', + '[attr.aria-expanded]': 'expanded()', + '[attr.aria-controls]': 'controls()', + '[attr.aria-disabled]': '_pattern.disabled()', + '[attr.tabindex]': '_pattern.tabIndex()', + '(keydown)': '_pattern.onKeydown($event)', + '(pointerdown)': '_pattern.onPointerdown($event)', + }, + providers: [{provide: DISCLOSURE_TRIGGER, useExisting: DisclosureTrigger}], +}) +export class DisclosureTrigger { + /** A reference to the host element. */ + private readonly _elementRef = inject(ElementRef); + + /** A reference to the host element. */ + readonly element = this._elementRef.nativeElement as HTMLElement; + + /** A unique identifier for the trigger. */ + readonly id = input(inject(_IdGenerator).getId('ng-disclosure-trigger-', true)); + + /** Whether the disclosure content is expanded. */ + readonly expanded = model(false); + + /** Whether the disclosure trigger is disabled. */ + readonly disabled = input(false, {transform: booleanAttribute}); + + /** Whether the disclosure is always expanded and cannot be closed. */ + readonly alwaysExpanded = input(false, {transform: booleanAttribute}); + + /** The ID of the controlled content element. */ + readonly controls = input(); + + /** The UI pattern instance for this disclosure trigger. */ + readonly _pattern: DisclosurePattern = new DisclosurePattern({ + id: this.id, + element: computed(() => this._elementRef.nativeElement), + expanded: this.expanded, + disabled: this.disabled, + alwaysExpanded: this.alwaysExpanded, + controls: this.controls, + }); + + /** Expands the disclosure content. */ + expand(): void { + this._pattern.open(); + } + + /** Collapses the disclosure content. */ + collapse(): void { + this._pattern.close(); + } + + /** Toggles the disclosure content visibility. */ + toggle(): void { + this._pattern.toggle(); + } +} diff --git a/src/aria/disclosure/disclosure.spec.ts b/src/aria/disclosure/disclosure.spec.ts new file mode 100644 index 000000000000..f73921a2115d --- /dev/null +++ b/src/aria/disclosure/disclosure.spec.ts @@ -0,0 +1,390 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {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'; +import {_IdGenerator} from '@angular/cdk/a11y'; +import {DisclosureTrigger} from './disclosure-trigger'; +import {DisclosureContent} from './disclosure-content'; + +/** + * Tests organized according to POUR principles: + * @see https://www.w3.org/WAI/fundamentals/accessibility-principles/ + * + * - Perceivable: Content visibility and presentation + * - Operable: Keyboard, pointer, and programmatic interactions + * - Understandable: Predictable behavior and state management + * - Robust: ARIA attributes and assistive technology compatibility + * + * Note: This file tests Angular directives that interact with the DOM and apply ARIA attributes. + * For the framework-agnostic pattern logic tests, see: src/aria/private/disclosure/disclosure.spec.ts + */ +describe('Disclosure Directives', () => { + let fixture: ComponentFixture; + let triggerDebugElement: DebugElement; + let contentDebugElement: DebugElement; + let triggerElement: HTMLElement; + let contentElement: HTMLElement; + let component: DisclosureTestComponent; + + function keydown(target: HTMLElement, key: string, keyCode: number) { + target.dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, key, keyCode})); + fixture.detectChanges(); + } + + function pointerdown(target: HTMLElement) { + target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, button: 0})); + fixture.detectChanges(); + } + + const space = (target: HTMLElement) => keydown(target, ' ', 32); + const enter = (target: HTMLElement) => keydown(target, 'Enter', 13); + + function setupTest() { + fixture.detectChanges(); + triggerDebugElement = fixture.debugElement.query(By.directive(DisclosureTrigger)); + contentDebugElement = fixture.debugElement.query(By.directive(DisclosureContent)); + triggerElement = triggerDebugElement.nativeElement; + contentElement = contentDebugElement.nativeElement; + component = fixture.componentInstance; + } + + /** + * Accessibility is validated after each test using axe-core. + * @see https://github.com/dequelabs/axe-core + */ + afterEach(async () => { + await runAccessibilityChecks(fixture.nativeElement); + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality('ltr'), _IdGenerator], + }); + + fixture = TestBed.createComponent(DisclosureTestComponent); + }); + + /** + * PERCEIVABLE + * Content visibility, Content identification + * + * Information and user interface components must be presentable to users + * in ways they can perceive. + * + * @see https://www.w3.org/WAI/fundamentals/accessibility-principles/#perceivable + */ + describe('Perceivable', () => { + describe('Content visibility (hidden attribute)', () => { + beforeEach(() => setupTest()); + + it('should hide content when collapsed', () => { + expect(contentElement.hasAttribute('hidden')).toBeTrue(); + }); + + it('should show content when expanded', () => { + component.expanded.set(true); + fixture.detectChanges(); + expect(contentElement.hasAttribute('hidden')).toBeFalse(); + }); + + it('should toggle content visibility with trigger interaction', () => { + pointerdown(triggerElement); + expect(contentElement.hasAttribute('hidden')).toBeFalse(); + pointerdown(triggerElement); + expect(contentElement.hasAttribute('hidden')).toBeTrue(); + }); + }); + + describe('Content identification', () => { + beforeEach(() => setupTest()); + + it('should have an id attribute on content', () => { + expect(contentElement.id).toBeTruthy(); + expect(contentElement.id).toContain('disclosure-content-test'); + }); + }); + }); + + /** + * OPERABLE + * Keyboard interaction, Pointer interaction, Programmatic API, Focus management + * + * User interface components and navigation must be operable. + * + * @see https://www.w3.org/WAI/fundamentals/accessibility-principles/#operable + */ + describe('Operable', () => { + describe('Keyboard interaction', () => { + beforeEach(() => setupTest()); + + it('should expand on Enter key', () => { + expect(component.expanded()).toBeFalse(); + enter(triggerElement); + expect(component.expanded()).toBeTrue(); + }); + + it('should collapse on Enter key when expanded', () => { + component.expanded.set(true); + fixture.detectChanges(); + enter(triggerElement); + expect(component.expanded()).toBeFalse(); + }); + + it('should expand on Space key', () => { + expect(component.expanded()).toBeFalse(); + space(triggerElement); + expect(component.expanded()).toBeTrue(); + }); + + it('should collapse on Space key when expanded', () => { + component.expanded.set(true); + fixture.detectChanges(); + space(triggerElement); + expect(component.expanded()).toBeFalse(); + }); + + it('should not expand on Enter key when disabled', () => { + component.disabled.set(true); + fixture.detectChanges(); + enter(triggerElement); + expect(component.expanded()).toBeFalse(); + }); + + it('should not expand on Space key when disabled', () => { + component.disabled.set(true); + fixture.detectChanges(); + space(triggerElement); + expect(component.expanded()).toBeFalse(); + }); + }); + + describe('Pointer interaction', () => { + beforeEach(() => setupTest()); + + it('should expand on pointer click', () => { + expect(component.expanded()).toBeFalse(); + pointerdown(triggerElement); + expect(component.expanded()).toBeTrue(); + }); + + it('should collapse on pointer click when expanded', () => { + component.expanded.set(true); + fixture.detectChanges(); + pointerdown(triggerElement); + expect(component.expanded()).toBeFalse(); + }); + + it('should not expand on pointer click when disabled', () => { + component.disabled.set(true); + fixture.detectChanges(); + pointerdown(triggerElement); + expect(component.expanded()).toBeFalse(); + }); + }); + + describe('Programmatic API (expand, collapse, toggle)', () => { + beforeEach(() => setupTest()); + + it('should expand via expand() method', () => { + const trigger = triggerDebugElement.injector.get(DisclosureTrigger); + expect(component.expanded()).toBeFalse(); + trigger.expand(); + fixture.detectChanges(); + expect(component.expanded()).toBeTrue(); + expect(contentElement.hasAttribute('hidden')).toBeFalse(); + }); + + it('should collapse via collapse() method', () => { + component.expanded.set(true); + fixture.detectChanges(); + const trigger = triggerDebugElement.injector.get(DisclosureTrigger); + trigger.collapse(); + fixture.detectChanges(); + expect(component.expanded()).toBeFalse(); + expect(contentElement.hasAttribute('hidden')).toBeTrue(); + }); + + it('should toggle via toggle() method', () => { + const trigger = triggerDebugElement.injector.get(DisclosureTrigger); + expect(component.expanded()).toBeFalse(); + + trigger.toggle(); + fixture.detectChanges(); + expect(component.expanded()).toBeTrue(); + + trigger.toggle(); + fixture.detectChanges(); + expect(component.expanded()).toBeFalse(); + }); + }); + + describe('Focus management (tabindex)', () => { + beforeEach(() => setupTest()); + + it('should have tabindex="0" when not disabled', () => { + expect(triggerElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should have tabindex="-1" when disabled', () => { + component.disabled.set(true); + fixture.detectChanges(); + expect(triggerElement.getAttribute('tabindex')).toBe('-1'); + }); + }); + }); + + /** + * UNDERSTANDABLE + * Predictable behavior, Consistent behavior + * + * Information and the operation of the user interface must be understandable. + * + * @see https://www.w3.org/WAI/fundamentals/accessibility-principles/#understandable + */ + describe('Understandable', () => { + describe('Predictable behavior (two-way binding)', () => { + beforeEach(() => setupTest()); + + it('should update expanded signal when toggled via pointer', () => { + expect(component.expanded()).toBeFalse(); + pointerdown(triggerElement); + expect(component.expanded()).toBeTrue(); + }); + + it('should update expanded signal when toggled via keyboard', () => { + expect(component.expanded()).toBeFalse(); + enter(triggerElement); + expect(component.expanded()).toBeTrue(); + }); + + it('should reflect external expanded changes in ARIA attributes', () => { + expect(triggerElement.getAttribute('aria-expanded')).toBe('false'); + component.expanded.set(true); + fixture.detectChanges(); + expect(triggerElement.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should reflect external expanded changes in content visibility', () => { + expect(contentElement.hasAttribute('hidden')).toBeTrue(); + component.expanded.set(true); + fixture.detectChanges(); + expect(contentElement.hasAttribute('hidden')).toBeFalse(); + }); + }); + + describe('Consistent behavior (alwaysExpanded)', () => { + beforeEach(() => { + component = fixture.componentInstance; + component.alwaysExpanded.set(true); + component.expanded.set(true); + setupTest(); + }); + + it('should not collapse on pointer click when alwaysExpanded', () => { + expect(component.expanded()).toBeTrue(); + pointerdown(triggerElement); + expect(component.expanded()).toBeTrue(); + }); + + it('should not collapse on Enter key when alwaysExpanded', () => { + expect(component.expanded()).toBeTrue(); + enter(triggerElement); + expect(component.expanded()).toBeTrue(); + }); + + it('should not collapse on Space key when alwaysExpanded', () => { + expect(component.expanded()).toBeTrue(); + space(triggerElement); + expect(component.expanded()).toBeTrue(); + }); + }); + }); + + /** + * ROBUST + * ARIA roles, ARIA states, ARIA properties + * + * Content must be robust enough to be interpreted reliably by a wide variety + * of user agents, including assistive technologies. + * + * @see https://www.w3.org/WAI/fundamentals/accessibility-principles/#robust + */ + describe('Robust', () => { + describe('ARIA roles', () => { + beforeEach(() => setupTest()); + + it('should have role="button" on trigger', () => { + expect(triggerElement.getAttribute('role')).toBe('button'); + }); + }); + + describe('ARIA states (aria-expanded)', () => { + beforeEach(() => setupTest()); + + it('should have aria-expanded="false" when collapsed', () => { + expect(triggerElement.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should have aria-expanded="true" when expanded', () => { + component.expanded.set(true); + fixture.detectChanges(); + expect(triggerElement.getAttribute('aria-expanded')).toBe('true'); + }); + }); + + describe('ARIA properties (aria-controls, aria-disabled)', () => { + beforeEach(() => setupTest()); + + it('should have aria-controls pointing to the content id', () => { + expect(triggerElement.getAttribute('aria-controls')).toBe(contentElement.id); + }); + + it('should have aria-disabled="false" when not disabled', () => { + expect(triggerElement.getAttribute('aria-disabled')).toBe('false'); + }); + + it('should have aria-disabled="true" when disabled', () => { + component.disabled.set(true); + fixture.detectChanges(); + expect(triggerElement.getAttribute('aria-disabled')).toBe('true'); + }); + }); + }); +}); + +@Component({ + template: ` + +
+

Disclosure content

+
+ `, + imports: [DisclosureTrigger, DisclosureContent], +}) +class DisclosureTestComponent { + contentId = 'disclosure-content-test'; + expanded = signal(false); + disabled = signal(false); + alwaysExpanded = signal(false); +} diff --git a/src/aria/disclosure/index.ts b/src/aria/disclosure/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/aria/disclosure/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './public-api'; diff --git a/src/aria/disclosure/public-api.ts b/src/aria/disclosure/public-api.ts new file mode 100644 index 000000000000..cb29e64e6cea --- /dev/null +++ b/src/aria/disclosure/public-api.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export {DisclosureTrigger} from './disclosure-trigger'; +export {DisclosureContent} from './disclosure-content'; + +// This needs to be re-exported, because it's used by the disclosure components. +// See: https://github.com/angular/components/issues/30663. +export { + DeferredContent as ɵɵDeferredContent, + DeferredContentAware as ɵɵDeferredContentAware, +} from '../private'; diff --git a/src/aria/private/BUILD.bazel b/src/aria/private/BUILD.bazel index f688ab1b20e1..7c152407d501 100644 --- a/src/aria/private/BUILD.bazel +++ b/src/aria/private/BUILD.bazel @@ -14,6 +14,7 @@ ts_project( "//src/aria/private/behaviors/signal-like", "//src/aria/private/combobox", "//src/aria/private/deferred-content", + "//src/aria/private/disclosure", "//src/aria/private/grid", "//src/aria/private/listbox", "//src/aria/private/menu", diff --git a/src/aria/private/disclosure/BUILD.bazel b/src/aria/private/disclosure/BUILD.bazel new file mode 100644 index 000000000000..8a58f9f7994a --- /dev/null +++ b/src/aria/private/disclosure/BUILD.bazel @@ -0,0 +1,33 @@ +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "disclosure", + srcs = [ + "disclosure.ts", + ], + deps = [ + "//src/aria/private/behaviors/event-manager", + "//src/aria/private/behaviors/signal-like", + ], +) + +ng_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "disclosure.spec.ts", + ], + deps = [ + ":disclosure", + "//src/aria/private/behaviors/signal-like", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/aria/private/disclosure/disclosure.spec.ts b/src/aria/private/disclosure/disclosure.spec.ts new file mode 100644 index 000000000000..1f5fc2e23b30 --- /dev/null +++ b/src/aria/private/disclosure/disclosure.spec.ts @@ -0,0 +1,314 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {DisclosureInputs, DisclosurePattern} from './disclosure'; +import {signal, SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; + +// Converts the SignalLike type to WritableSignalLike type for controlling test inputs. +type WritableSignalOverrides = { + [K in keyof O as O[K] extends SignalLike ? K : never]: O[K] extends SignalLike + ? WritableSignalLike + : never; +}; + +type TestDisclosureInputs = DisclosureInputs & WritableSignalOverrides; + +// Keyboard event helpers +const space = () => createKeyboardEvent('keydown', 32, ' '); +const enter = () => createKeyboardEvent('keydown', 13, 'Enter'); +const escape = () => createKeyboardEvent('keydown', 27, 'Escape'); +const tab = () => createKeyboardEvent('keydown', 9, 'Tab'); + +function createTriggerElement(): HTMLElement { + const element = document.createElement('button'); + element.setAttribute('role', 'button'); + return element; +} + +function createPointerEvent(): PointerEvent { + return {button: 0} as PointerEvent; +} + +/** + * Tests organized according to POUR principles: + * @see https://www.w3.org/WAI/fundamentals/accessibility-principles/ + * + * - Operable: Keyboard, pointer interactions, and programmatic API + * - Understandable: Predictable behavior and state management + * - Robust: Validation and error handling + * + * Note: Perceivable is not tested here because this is a framework-agnostic pattern class + * that handles logic and state management only. DOM attributes (hidden, aria-expanded, etc.) + * are tested in the public Angular directive tests: src/aria/disclosure/disclosure.spec.ts + */ +describe('Disclosure Pattern', () => { + let inputs: TestDisclosureInputs; + let pattern: DisclosurePattern; + + beforeEach(() => { + inputs = { + id: signal('disclosure-trigger'), + element: signal(createTriggerElement()), + expanded: signal(false), + disabled: signal(false), + alwaysExpanded: signal(false), + controls: signal('disclosure-content'), + }; + pattern = new DisclosurePattern(inputs); + }); + + /** + * OPERABLE + * Keyboard interaction, Pointer interaction, Programmatic API, Focus management + * + * User interface components and navigation must be operable. + * + * @see https://www.w3.org/WAI/fundamentals/accessibility-principles/#operable + */ + describe('Operable', () => { + describe('Keyboard interaction', () => { + it('should toggle expansion on Space key.', () => { + expect(pattern.expanded()).toBeFalse(); + pattern.onKeydown(space()); + expect(pattern.expanded()).toBeTrue(); + pattern.onKeydown(space()); + expect(pattern.expanded()).toBeFalse(); + }); + + it('should toggle expansion on Enter key.', () => { + expect(pattern.expanded()).toBeFalse(); + pattern.onKeydown(enter()); + expect(pattern.expanded()).toBeTrue(); + pattern.onKeydown(enter()); + expect(pattern.expanded()).toBeFalse(); + }); + + it('should not toggle expansion on other keys.', () => { + expect(pattern.expanded()).toBeFalse(); + pattern.onKeydown(escape()); + expect(pattern.expanded()).toBeFalse(); + pattern.onKeydown(tab()); + expect(pattern.expanded()).toBeFalse(); + }); + + it('should not toggle expansion on Space key when disabled.', () => { + inputs.disabled.set(true); + pattern.onKeydown(space()); + expect(pattern.expanded()).toBeFalse(); + }); + + it('should not toggle expansion on Enter key when disabled.', () => { + inputs.disabled.set(true); + pattern.onKeydown(enter()); + expect(pattern.expanded()).toBeFalse(); + }); + }); + + describe('Pointer interaction', () => { + it('should toggle expansion on pointer click.', () => { + expect(pattern.expanded()).toBeFalse(); + pattern.onPointerdown(createPointerEvent()); + expect(pattern.expanded()).toBeTrue(); + pattern.onPointerdown(createPointerEvent()); + expect(pattern.expanded()).toBeFalse(); + }); + + it('should not toggle expansion on pointer click when disabled.', () => { + inputs.disabled.set(true); + pattern.onPointerdown(createPointerEvent()); + expect(pattern.expanded()).toBeFalse(); + }); + }); + + describe('Programmatic API (open, close, toggle)', () => { + it('should be collapsed by default.', () => { + expect(pattern.expanded()).toBeFalse(); + }); + + it('should expand when open() is called.', () => { + pattern.open(); + expect(pattern.expanded()).toBeTrue(); + }); + + it('should collapse when close() is called.', () => { + inputs.expanded.set(true); + expect(pattern.expanded()).toBeTrue(); + pattern.close(); + expect(pattern.expanded()).toBeFalse(); + }); + + it('should toggle expansion state when toggle() is called.', () => { + expect(pattern.expanded()).toBeFalse(); + pattern.toggle(); + expect(pattern.expanded()).toBeTrue(); + pattern.toggle(); + expect(pattern.expanded()).toBeFalse(); + }); + + it('should remain expanded when open() is called while already expanded.', () => { + inputs.expanded.set(true); + pattern.open(); + expect(pattern.expanded()).toBeTrue(); + }); + + it('should remain collapsed when close(i) is called while already collapsed.', () => { + pattern.close(); + expect(pattern.expanded()).toBeFalse(); + }); + }); + + describe('Focus management (tabIndex)', () => { + it('should return 0 when not disabled.', () => { + expect(pattern.tabIndex()).toBe(0); + }); + + it('should return -1 when disabled.', () => { + inputs.disabled.set(true); + expect(pattern.tabIndex()).toBe(-1); + }); + }); + }); + + /** + * UNDERSTANDABLE + * Default state, Disabled state, Always expanded behavior + * + * Information and the operation of the user interface must be understandable. + * + * @see https://www.w3.org/WAI/fundamentals/accessibility-principles/#understandable + */ + describe('Understandable', () => { + describe('Default state initialization', () => { + it('should set expanded to true when alwaysExpanded is true and expanded is false.', () => { + inputs.alwaysExpanded.set(true); + inputs.expanded.set(false); + pattern.setDefaultState(); + expect(pattern.expanded()).toBeTrue(); + }); + + it('should not change expanded when alwaysExpanded is false.', () => { + inputs.alwaysExpanded.set(false); + inputs.expanded.set(false); + pattern.setDefaultState(); + expect(pattern.expanded()).toBeFalse(); + }); + + it('should keep expanded true when alwaysExpanded and expanded are both true.', () => { + inputs.alwaysExpanded.set(true); + inputs.expanded.set(true); + pattern.setDefaultState(); + expect(pattern.expanded()).toBeTrue(); + }); + }); + + describe('Consistent behavior (alwaysExpanded)', () => { + beforeEach(() => { + inputs.alwaysExpanded.set(true); + inputs.expanded.set(true); + }); + + it('should not collapse when close() is called.', () => { + pattern.close(); + expect(pattern.expanded()).toBeTrue(); + }); + + it('should not collapse when toggle() is called while expanded.', () => { + pattern.toggle(); + expect(pattern.expanded()).toBeTrue(); + }); + + it('should expand when open() is called.', () => { + inputs.expanded.set(false); + pattern.open(); + expect(pattern.expanded()).toBeTrue(); + }); + + it('should not collapse on Space key.', () => { + pattern.onKeydown(space()); + expect(pattern.expanded()).toBeTrue(); + }); + + it('should not collapse on Enter key.', () => { + pattern.onKeydown(enter()); + expect(pattern.expanded()).toBeTrue(); + }); + + it('should not collapse on pointer click.', () => { + pattern.onPointerdown(createPointerEvent()); + expect(pattern.expanded()).toBeTrue(); + }); + }); + + describe('Disabled state behavior', () => { + beforeEach(() => { + inputs.disabled.set(true); + }); + + it('should report disabled state.', () => { + expect(pattern.disabled()).toBeTrue(); + }); + + it('should not expand when open() is called.', () => { + pattern.open(); + expect(pattern.expanded()).toBeFalse(); + }); + + it('should not collapse when close() is called.', () => { + inputs.expanded.set(true); + inputs.disabled.set(true); + pattern.close(); + expect(pattern.expanded()).toBeTrue(); + }); + + it('should not toggle when toggle() is called.', () => { + pattern.toggle(); + expect(pattern.expanded()).toBeFalse(); + inputs.expanded.set(true); + pattern.toggle(); + expect(pattern.expanded()).toBeTrue(); + }); + }); + }); + + /** + * ROBUST + * Validation and error handling + * + * Content must be robust enough to be interpreted reliably by a wide variety + * of user agents, including assistive technologies. + * + * @see https://www.w3.org/WAI/fundamentals/accessibility-principles/#robust + */ + describe('Robust', () => { + describe('Validation', () => { + it('should return no errors for valid collapsed state.', () => { + expect(pattern.validate()).toEqual([]); + }); + + it('should return no errors for valid expanded state.', () => { + inputs.expanded.set(true); + expect(pattern.validate()).toEqual([]); + }); + + it('should return no errors when alwaysExpanded and expanded are both true.', () => { + inputs.alwaysExpanded.set(true); + inputs.expanded.set(true); + expect(pattern.validate()).toEqual([]); + }); + + it('should return error when alwaysExpanded is true but expanded is false.', () => { + inputs.alwaysExpanded.set(true); + inputs.expanded.set(false); + const errors = pattern.validate(); + expect(errors.length).toBe(1); + expect(errors[0]).toContain('Disclosure: alwaysExpanded is true but expanded is false.'); + }); + }); + }); +}); diff --git a/src/aria/private/disclosure/disclosure.ts b/src/aria/private/disclosure/disclosure.ts new file mode 100644 index 000000000000..fd2c66f48418 --- /dev/null +++ b/src/aria/private/disclosure/disclosure.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; +import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; +import {computed, SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; + +/** Represents the required inputs for the DisclosurePattern. */ +export interface DisclosureInputs { + /** A unique identifier for the disclosure trigger. */ + id: SignalLike; + + /** A reference to the trigger element. */ + element: SignalLike; + + /** Whether the disclosure content is expanded. */ + expanded: WritableSignalLike; + + /** Whether the disclosure trigger is disabled. */ + disabled: SignalLike; + + /** Whether the disclosure is always expanded and cannot be closed. */ + alwaysExpanded: SignalLike; + + /** The ID of the controlled content element. */ + controls: SignalLike; +} + +/** + * A pattern that controls the expansion state of a disclosure widget. + * + * A disclosure is a widget that enables content to be either collapsed (hidden) + * or expanded (visible). It has a button that controls visibility of the content. + */ +export class DisclosurePattern { + /** The unique identifier for this disclosure trigger. */ + readonly id: SignalLike; + + /** A reference to the trigger element. */ + readonly element: SignalLike; + + /** Whether the disclosure content is expanded. */ + readonly expanded: WritableSignalLike; + + /** Whether the disclosure trigger is disabled. */ + readonly disabled: SignalLike; + + /** Whether the disclosure is always expanded and cannot be closed. */ + readonly alwaysExpanded: SignalLike; + + /** The ID of the controlled content element. */ + readonly controls: SignalLike; + + /** The tabindex for the trigger. */ + readonly tabIndex = computed(() => (this.disabled() ? -1 : 0)); + + /** The keydown event manager for the disclosure trigger. */ + readonly keydown = computed(() => { + return new KeyboardEventManager().on('Enter', () => this.toggle()).on(' ', () => this.toggle()); + }); + + /** The pointerdown event manager for the disclosure trigger. */ + readonly pointerdown = computed(() => { + return new PointerEventManager().on(() => this.toggle()); + }); + + constructor(readonly inputs: DisclosureInputs) { + this.id = inputs.id; + this.element = inputs.element; + this.expanded = inputs.expanded; + this.disabled = inputs.disabled; + this.alwaysExpanded = inputs.alwaysExpanded; + this.controls = inputs.controls; + } + + /** Checks that the internal state of the pattern is valid. */ + validate(): string[] { + const errors: string[] = []; + + if (this.alwaysExpanded() && !this.expanded()) { + errors.push('Disclosure: alwaysExpanded is true but expanded is false.'); + } + + return errors; + } + + /** Sets the default initial state of the disclosure. */ + setDefaultState(): void { + if (this.alwaysExpanded() && !this.expanded()) { + this.expanded.set(true); + } + } + + /** Handles keydown events for the disclosure trigger. */ + onKeydown(event: KeyboardEvent): void { + if (this.disabled()) return; + this.keydown().handle(event); + } + + /** Handles pointer events for the disclosure trigger. */ + onPointerdown(event: PointerEvent): void { + if (this.disabled()) return; + this.pointerdown().handle(event); + } + + /** Opens the disclosure content. */ + open(): void { + if (this.disabled()) return; + this.expanded.set(true); + } + + /** Closes the disclosure content if not always expanded. */ + close(): void { + if (this.disabled()) return; + if (this.alwaysExpanded()) return; + this.expanded.set(false); + } + /** Toggles the disclosure content visibility. */ + toggle(): void { + if (this.disabled()) return; + + if (this.expanded()) { + this.close(); + } else { + this.open(); + } + } +} diff --git a/src/aria/private/public-api.ts b/src/aria/private/public-api.ts index ed8716c7b67b..c4008f3e3df2 100644 --- a/src/aria/private/public-api.ts +++ b/src/aria/private/public-api.ts @@ -25,3 +25,4 @@ export * from './grid/row'; export * from './grid/cell'; export * from './grid/widget'; export * from './deferred-content'; +export * from './disclosure/disclosure';