Skip to content

Commit b44abf2

Browse files
feat(aria/disclosure): add disclosure pattern
Implements the WAI-ARIA Disclosure pattern with two directives. DisclosureTrigger (ngDisclosureTrigger) is a toggle button that controls content visibility with keyboard support (Enter, Space) and ARIA attributes (aria-expanded, aria-controls). DisclosureContent (ngDisclosureContent) is a content panel that shows/hides based on trigger state.
1 parent e78587f commit b44abf2

File tree

16 files changed

+1502
-0
lines changed

16 files changed

+1502
-0
lines changed

.ng-dev/commit-message.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const commitMessage: CommitMessageConfig = {
1111
'multiple', // For when a commit applies to multiple components.
1212
'aria/accordion',
1313
'aria/combobox',
14+
'aria/disclosure',
1415
'aria/grid',
1516
'aria/listbox',
1617
'aria/menu',

docs/src/app/shared/doc-viewer/angular-aria-banner/angular-aria-banner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const ANGULAR_ARIA_LINKS: Record<string, string> = {
1717
'tree': 'https://angular.dev/guide/aria/tree',
1818
'accordion': 'https://angular.dev/guide/aria/accordion',
1919
'menu': 'https://angular.dev/guide/aria/menu',
20+
'disclosure': 'https://angular.dev/guide/aria/disclosure',
2021
};
2122

2223
/**

guides/aria-disclosure.md

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
# Disclosure
2+
3+
<a href="https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/" target="_blank">Disclosure ARIA pattern</a> <a href="api/aria/disclosure">Disclosure API Reference</a>
4+
5+
## Overview
6+
7+
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.
8+
9+
### app.ts
10+
11+
```typescript
12+
import {ChangeDetectionStrategy, Component, signal} from '@angular/core';
13+
import {DisclosureTrigger, DisclosureContent} from '@angular/aria/disclosure';
14+
15+
@Component({
16+
selector: 'app-root',
17+
templateUrl: 'app.html',
18+
styleUrl: 'app.css',
19+
imports: [DisclosureTrigger, DisclosureContent],
20+
changeDetection: ChangeDetectionStrategy.OnPush,
21+
})
22+
export class App {
23+
devilFruits = [
24+
{
25+
id: 'gomu',
26+
name: 'Gomu Gomu no Mi',
27+
type: 'Paramecia',
28+
user: 'Monkey D. Luffy',
29+
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.',
30+
expanded: signal(true),
31+
},
32+
{
33+
id: 'mera',
34+
name: 'Mera Mera no Mi',
35+
type: 'Logia',
36+
user: 'Sabo (formerly Portgas D. Ace)',
37+
description: 'Allows the user to create, control, and transform into fire at will. One of the most powerful Logia-type Devil Fruits.',
38+
expanded: signal(false),
39+
},
40+
{
41+
id: 'ope',
42+
name: 'Ope Ope no Mi',
43+
type: 'Paramecia',
44+
user: 'Trafalgar D. Water Law',
45+
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.',
46+
expanded: signal(false),
47+
},
48+
];
49+
}
50+
```
51+
52+
### app.html
53+
54+
```html
55+
<h2>Devil Fruit Encyclopedia</h2>
56+
<div class="fruit-list">
57+
@for (fruit of devilFruits; track fruit.id) {
58+
<div class="fruit-card">
59+
<button
60+
ngDisclosureTrigger
61+
#trigger="ngDisclosureTrigger"
62+
[(expanded)]="fruit.expanded"
63+
[controls]="'fruit-' + fruit.id"
64+
class="fruit-trigger"
65+
>
66+
<span class="fruit-icon">{{ fruit.expanded() ? '▼' : '▶' }}</span>
67+
<span class="fruit-name">{{ fruit.name }}</span>
68+
<span class="fruit-type" [attr.data-type]="fruit.type">{{ fruit.type }}</span>
69+
</button>
70+
71+
<div
72+
[id]="'fruit-' + fruit.id"
73+
ngDisclosureContent
74+
[trigger]="trigger"
75+
class="fruit-details"
76+
>
77+
<p><strong>Current User:</strong> {{ fruit.user }}</p>
78+
<p>{{ fruit.description }}</p>
79+
</div>
80+
</div>
81+
}
82+
</div>
83+
```
84+
85+
### app.css
86+
87+
```css
88+
.fruit-list {
89+
display: flex;
90+
flex-direction: column;
91+
gap: 12px;
92+
max-width: 600px;
93+
font-family: system-ui, sans-serif;
94+
}
95+
96+
.fruit-card {
97+
border: 2px solid var(--gray-300, #d1d5db);
98+
border-radius: 12px;
99+
overflow: hidden;
100+
background: var(--white, #ffffff);
101+
}
102+
103+
.fruit-trigger {
104+
width: 100%;
105+
display: flex;
106+
align-items: center;
107+
gap: 12px;
108+
padding: 16px;
109+
background: var(--gray-50, #f9fafb);
110+
border: none;
111+
cursor: pointer;
112+
font-size: 1rem;
113+
text-align: left;
114+
transition: background-color 0.2s ease;
115+
}
116+
117+
.fruit-trigger:hover {
118+
background: var(--gray-100, #f3f4f6);
119+
}
120+
121+
.fruit-trigger:focus-visible {
122+
outline: 2px solid var(--vivid-pink, #f542a4);
123+
outline-offset: -2px;
124+
}
125+
126+
.fruit-icon {
127+
font-size: 0.75rem;
128+
color: var(--gray-500, #6b7280);
129+
}
130+
131+
.fruit-name {
132+
flex: 1;
133+
font-weight: 600;
134+
}
135+
136+
.fruit-type {
137+
padding: 4px 8px;
138+
border-radius: 4px;
139+
font-size: 0.75rem;
140+
font-weight: 500;
141+
}
142+
143+
.fruit-type[data-type='Paramecia'] {
144+
background: #dbeafe;
145+
color: #1e40af;
146+
}
147+
148+
.fruit-type[data-type='Logia'] {
149+
background: #fef3c7;
150+
color: #92400e;
151+
}
152+
153+
.fruit-type[data-type='Zoan'] {
154+
background: #d1fae5;
155+
color: #065f46;
156+
}
157+
158+
.fruit-details {
159+
padding: 16px;
160+
background: var(--white, #ffffff);
161+
border-top: 1px solid var(--gray-200, #e5e7eb);
162+
}
163+
164+
.fruit-details p {
165+
margin: 0 0 8px 0;
166+
color: var(--gray-700, #374151);
167+
line-height: 1.6;
168+
}
169+
170+
.fruit-details p:last-child {
171+
margin-bottom: 0;
172+
}
173+
```
174+
175+
## APIs
176+
177+
### DisclosureTrigger Directive
178+
179+
The `ngDisclosureTrigger` directive creates a button that toggles the visibility of associated content.
180+
181+
#### Inputs
182+
183+
| Property | Type | Default | Description |
184+
|----------|------|---------|-------------|
185+
| expanded | boolean | false | Whether the content is expanded |
186+
| disabled | boolean | false | Disables the trigger |
187+
| alwaysExpanded | boolean | false | Keeps content always visible, prevents collapsing |
188+
| controls | string | - | ID of the controlled content element |
189+
| id | string | auto-generated | Unique identifier for the trigger |
190+
191+
#### Signals
192+
193+
| Property | Type | Description |
194+
|----------|------|-------------|
195+
| expanded | ModelSignal\<boolean\> | Two-way bindable expanded state using [(expanded)] |
196+
197+
#### Methods
198+
199+
| Method | Parameters | Description |
200+
|--------|------------|-------------|
201+
| expand | none | Expands the content |
202+
| collapse | none | Collapses the content (respects alwaysExpanded) |
203+
| toggle | none | Toggles the expanded state |
204+
205+
### DisclosureContent Directive
206+
207+
The `ngDisclosureContent` directive marks an element as the content panel controlled by a trigger.
208+
209+
#### Inputs
210+
211+
| Property | Type | Default | Description |
212+
|----------|------|---------|-------------|
213+
| trigger | DisclosureTrigger | - | Reference to the controlling trigger |
214+
| id | string | auto-generated | Unique identifier for the content |
215+
| preserveContent | boolean | false | Whether to preserve DOM content when collapsed |
216+
217+
#### Signals
218+
219+
| Property | Type | Description |
220+
|----------|------|-------------|
221+
| hidden | Signal\<boolean\> | Whether the content is currently hidden |
222+
| visible | Signal\<boolean\> | Whether the content is currently visible |
223+
224+
### Keyboard Interaction
225+
226+
| Key | Action |
227+
|-----|--------|
228+
| Enter | Toggles the disclosure |
229+
| Space | Toggles the disclosure |
230+
231+
### ARIA Attributes
232+
233+
The directives automatically manage these accessibility attributes:
234+
235+
**Trigger element:**
236+
- `role="button"` - Identifies as interactive button
237+
- `aria-expanded` - `true` when expanded, `false` when collapsed
238+
- `aria-controls` - References the content element's ID
239+
- `aria-disabled` - `true` when disabled
240+
- `tabindex` - `0` when enabled, `-1` when disabled
241+
242+
**Content element:**
243+
- `id` - Unique identifier referenced by aria-controls
244+
- `hidden` - Present when collapsed (removed when expanded)
245+
246+
## Deferred Content
247+
248+
For performance optimization, combine with `ngDeferredContent` to delay rendering until first expansion:
249+
250+
```html
251+
<button ngDisclosureTrigger #trigger="ngDisclosureTrigger">
252+
Load Content
253+
</button>
254+
255+
<div ngDisclosureContent [trigger]="trigger">
256+
<ng-template ngDeferredContent>
257+
<!-- Only rendered when first expanded -->
258+
<heavy-component></heavy-component>
259+
</ng-template>
260+
</div>
261+
```
262+
263+
Use `preserveContent="true"` to keep content in the DOM after collapsing:
264+
265+
```html
266+
<div ngDisclosureContent [trigger]="trigger" [preserveContent]="true">
267+
<ng-template ngDeferredContent>
268+
<!-- Created once, preserved when collapsed -->
269+
<stateful-component></stateful-component>
270+
</ng-template>
271+
</div>
272+
```
273+
274+
## When to use Disclosure vs Accordion
275+
276+
### Key Differences
277+
278+
| Feature | Disclosure | Accordion |
279+
|---------|------------|-----------|
280+
| **Grouping** | Independent items | Grouped with `ngAccordionGroup` |
281+
| **Keyboard navigation** | Enter/Space only | Arrow keys, Home/End between items |
282+
| **Expansion mode** | Always independent | Configurable (`multiExpandable`) |
283+
| **ARIA pattern** | Simple button + content | Full accordion with regions |
284+
| **Focus management** | None | Roving tabindex |
285+
286+
### Use Disclosure when:
287+
288+
| Scenario | Example |
289+
|----------|---------|
290+
| **Simple show/hide** | "Read more" button, help tooltips |
291+
| **Single expandable item** | One collapsible section |
292+
| **No keyboard nav needed** | Users won't navigate between items with arrow keys |
293+
| **Lightweight interaction** | Minimal ARIA overhead |
294+
295+
```html
296+
<!-- Simple disclosure - Enter/Space to toggle -->
297+
<button ngDisclosureTrigger [(expanded)]="showDetails">Details</button>
298+
<div ngDisclosureContent [trigger]="trigger">...</div>
299+
```
300+
301+
### Use Accordion when:
302+
303+
| Scenario | Example |
304+
|----------|---------|
305+
| **Grouped related content** | FAQ sections, settings categories |
306+
| **Keyboard navigation needed** | Users navigate between items with arrow keys |
307+
| **Single expansion mode** | Set `[multiExpandable]="false"` for one-at-a-time |
308+
| **Complex panel management** | `expandAll()`, `collapseAll()` methods |
309+
310+
```html
311+
<!-- Accordion - arrow key navigation, grouped management -->
312+
<div ngAccordionGroup [multiExpandable]="false">
313+
<button ngAccordionTrigger panelId="p1">Panel 1</button>
314+
<div ngAccordionPanel panelId="p1">...</div>
315+
316+
<button ngAccordionTrigger panelId="p2">Panel 2</button>
317+
<div ngAccordionPanel panelId="p2">...</div>
318+
</div>
319+
```
320+
321+
### Quick decision guide
322+
323+
```
324+
Do you need keyboard navigation between items (arrow keys)?
325+
├── YES → Use Accordion
326+
└── NO → Is it a single item or independent items?
327+
├── Single/Independent → Use Disclosure
328+
└── Grouped with shared control → Use Accordion
329+
```
330+
331+
## Related patterns and directives
332+
333+
- **[Accordion](guide/aria/accordion)** - Grouped panels with keyboard navigation and optional single-expansion mode
334+
- **[Tabs](guide/aria/tabs)** - Content organized into tabbed panels
335+
336+
Disclosure can combine with:
337+
338+
- **DeferredContent** - Lazy rendering of content until first expansion

src/aria/config.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
ARIA_ENTRYPOINTS = [
33
"accordion",
44
"combobox",
5+
"disclosure",
56
"grid",
67
"listbox",
78
"menu",

0 commit comments

Comments
 (0)