Accordion
<oge-accordion> stacks disclosure panels that come from projected <oge-accordion-item> children, from a data-driven items array, or both. It follows the WAI-ARIA APG accordion pattern — each title is a <button> inside a heading and every header stays in the page Tab sequence — and layers arrow / Home / End / type-ahead navigation on top. Height animation, RTL and prefers-reduced-motion work out of the box.
Declarative panels
Projected children carry their own content. selectedIndex is a two-way model for the single-expand case; a user gesture first fires the cancelable itemExpanding, then itemExpanded. collapsible lets a second click close the open panel — without it the last open panel deliberately stays open (see the third demo). Disabled panels are skipped by clicks and arrow keys.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeAccordion, OgeAccordionItem } from '@oge-ui/layout';
@Component({
selector: 'demo-root',
imports: [OgeAccordion, OgeAccordionItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- collapsible lets a second click close the open panel; without
it the last open panel deliberately stays open (dx behavior, APG-marked
aria-disabled). -->
<oge-accordion [(selectedIndex)]="index" [collapsible]="true">
<oge-accordion-item title="Account" description="Name and e-mail">
Account settings…
</oge-accordion-item>
<oge-accordion-item title="Notifications" [badge]="3">
Notification settings…
</oge-accordion-item>
<oge-accordion-item title="Archived" [disabled]="true">
Never reachable…
</oge-accordion-item>
</oge-accordion>
`,
})
export class Demo {
protected readonly index = signal(0);
}Data-driven items
The items array drives the panels; expandedKeys is the multi-expand two-way model, so state survives reordering and insertions. icon takes raw SVG path data — there is no icon font or icon package. A component-level ogeAccordionContentTemplate renders every item's body.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeAccordion, OgeAccordionContentTemplate } from '@oge-ui/layout';
import type { OgeAccordionItemData } from '@oge-ui/layout';
@Component({
selector: 'demo-root',
imports: [OgeAccordion, OgeAccordionContentTemplate],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-accordion [items]="sections" [multiple]="true" [(expandedKeys)]="open">
<ng-template ogeAccordionContentTemplate let-item>
Body of <b>{{ item.title }}</b>…
</ng-template>
</oge-accordion>
`,
})
export class Demo {
protected readonly sections: OgeAccordionItemData[] = [
{ key: 'general', title: 'General', description: 'Language and time zone' },
{ key: 'security', title: 'Security', badge: 2 },
{ key: 'danger', title: 'Danger zone' },
];
protected readonly open = signal<string[]>(['general']);
}Single, multiple & collapsible
Single-expand collapses the sibling automatically. Without collapsible the last open panel cannot be closed, and the APG says such a header gets aria-disabled="true" — not disabled, so it stays focusable. Toggle the switches and watch the open header.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeAccordion } from '@oge-ui/layout';
import type { OgeAccordionItemData } from '@oge-ui/layout';
@Component({
selector: 'demo-root',
imports: [OgeAccordion],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- multiple: several panels stay open.
collapsible: the last open panel may be closed, leaving none.
Without collapsible the open panel is aria-disabled (APG). -->
<oge-accordion [multiple]="multiple()" [collapsible]="collapsible()" [items]="items" />
`,
})
export class Demo {
protected readonly multiple = signal(true);
protected readonly collapsible = signal(true);
protected readonly items: OgeAccordionItemData[] = [
{ key: 'a', title: 'First' },
{ key: 'b', title: 'Second' },
];
}Lazy rendering & keep-alive
With deferRendering (default) a lazy ogeAccordionContentTemplate is instantiated on first expand; keepAlive (default) then keeps it mounted while collapsed — the creation time does not change when you reopen. Turn keep-alive off and the content is recreated every time.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeAccordion, OgeAccordionContentTemplate, OgeAccordionItem } from '@oge-ui/layout';
@Component({
selector: 'demo-root',
imports: [OgeAccordion, OgeAccordionContentTemplate, OgeAccordionItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- deferRendering (default true): content is created on first expand.
keepAlive (default true): it then stays mounted while collapsed. -->
<oge-accordion [keepAlive]="keepAlive()" [multiple]="true" [collapsible]="true">
<oge-accordion-item title="First">
<ng-template ogeAccordionContentTemplate>Created at {{ stamp() }}</ng-template>
</oge-accordion-item>
</oge-accordion>
`,
})
export class Demo {
protected readonly keepAlive = signal(true);
protected stamp(): string {
return new Date().toLocaleTimeString();
}
}Async expand guard
Expanding runs a pipeline: cancelable itemExpanding → the panel's async expandGuard (the header shows a spinner, extra clicks are ignored) → itemExpanded. The guard also runs on collapse. Resolving false, throwing and rejecting all veto. The guarded panel here takes a second to confirm.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeAccordion } from '@oge-ui/layout';
import type { OgeAccordionItemData } from '@oge-ui/layout';
@Component({
selector: 'demo-root',
imports: [OgeAccordion],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-accordion [items]="guarded" [multiple]="true" [collapsible]="true" />
`,
})
export class Demo {
// resolve(false) vetoes, rejection vetoes too; while the promise is
// pending the header shows a spinner and ignores further clicks
protected readonly guarded: OgeAccordionItemData[] = [
{ key: 'plain', title: 'Opens right away' },
{ key: 'slow', title: 'Confirms first', expandGuard: () => this.confirm() },
];
private confirm(): boolean {
return confirm('Open this section?');
}
}Invalid sections
Flag a panel invalid and it grows a danger rail, a dot beside the title and a visually hidden label so screen readers announce it. expandInvalid() opens every failing section at once — the natural move after a rejected form submit.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeAccordion } from '@oge-ui/layout';
import type { OgeAccordionItemData } from '@oge-ui/layout';
@Component({
selector: 'demo-root',
imports: [OgeAccordion],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-accordion [items]="formSections" [multiple]="true" [collapsible]="true" #acc />
<button type="button" (click)="acc.expandInvalid()">Show all errors</button>
`,
})
export class Demo {
// flag the sections your form group reports as invalid
protected readonly formSections: OgeAccordionItemData[] = [
{ key: 'contact', title: 'Contact' },
{ key: 'billing', title: 'Billing', invalid: true },
{ key: 'shipping', title: 'Shipping', invalid: true },
];
}Async content loader
A per-panel contentLoader runs on first expand: a shimmering skeleton shows while it is pending, the resolved value reaches the content template as data, and a rejection renders the failure message with a real retry button. The second panel fails once, then succeeds.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeAccordion, OgeAccordionContentTemplate, OgeAccordionItem } from '@oge-ui/layout';
@Component({
selector: 'demo-root',
imports: [OgeAccordion, OgeAccordionContentTemplate, OgeAccordionItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-accordion [multiple]="true" [collapsible]="true">
<oge-accordion-item title="Invoices" [contentLoader]="loadInvoices">
<ng-template ogeAccordionContentTemplate let-data="data">
{{ data }}
</ng-template>
</oge-accordion-item>
</oge-accordion>
`,
})
export class Demo {
// a skeleton shows while pending; a rejection renders a retry button
protected readonly loadInvoices = () =>
new Promise<string>((resolve) =>
setTimeout(() => resolve('42 invoices'), 900),
);
}Header actions
The APG puts the panel title in a <button>, so a second focusable control cannot live inside it — axe flags that as nested-interactive. Header actions are therefore rendered as siblings of the toggle: real buttons, reachable with Tab, skipped by the accordion's arrow navigation.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeAccordion, OgeAccordionHeaderActionsTemplate, OgeAccordionItem } from '@oge-ui/layout';
@Component({
selector: 'demo-root',
imports: [OgeAccordion, OgeAccordionHeaderActionsTemplate, OgeAccordionItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- APG puts the panel title in a <button>; a second focusable control
inside it would be a nested-interactive violation. Header actions are
rendered as siblings of that button, so they are real, Tab-reachable
controls and arrow navigation skips them. -->
<oge-accordion>
<oge-accordion-item title="Team">
<ng-template ogeAccordionHeaderActionsTemplate let-index="index">
<button type="button" (click)="remove(index)">Remove</button>
</ng-template>
Members…
</oge-accordion-item>
</oge-accordion>
`,
})
export class Demo {
protected remove(index: number): void {
console.log('remove section', index);
}
}Panel-level control
Each panel owns a two-way [(expanded)] and imperative open()/close()/toggle() — writes go through the same pipeline, so a guard veto reverts the binding. [ogeAccordionActionRow] is the footer action bar (the references' action-row slot), and afterExpand/afterCollapse fire once the height animation settles. Collapsing a panel that holds focus hands focus back to its header.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeAccordion, OgeAccordionActionRow, OgeAccordionItem } from '@oge-ui/layout';
import type { OgeAccordionExpandedEvent } from '@oge-ui/layout';
@Component({
selector: 'demo-root',
imports: [OgeAccordion, OgeAccordionActionRow, OgeAccordionItem],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- [(expanded)] is two-way per panel, and writing to it still runs the
pipeline — a veto reverts the binding. #p exposes open()/close()/toggle().
afterExpand fires once the height animation settles. -->
<oge-accordion (afterExpand)="onSettled($event)">
<oge-accordion-item #p title="Profile" [(expanded)]="profileOpen">
<p>…fields…</p>
<div ogeAccordionActionRow>
<button type="button" (click)="p.close()">Cancel</button>
<button type="button" (click)="save()">Save</button>
</div>
</oge-accordion-item>
</oge-accordion>
`,
})
export class Demo {
protected readonly profileOpen = signal(true);
protected onSettled(event: OgeAccordionExpandedEvent): void {
console.log('settled', event.key);
}
protected save(): void {
console.log('saved');
}
}Toggle position & styling
togglePosition is logical, so RTL mirrors it for free. displayMode: 'flat' drops the gutters and joins the panels into one stack, stylingMode switches between outlined, filled and borderless, and size sets the header density.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeAccordion } from '@oge-ui/layout';
import type { OgeAccordionItemData } from '@oge-ui/layout';
@Component({
selector: 'demo-root',
imports: [OgeAccordion],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<oge-accordion
[items]="items"
togglePosition="start"
displayMode="flat"
stylingMode="filled"
size="sm"
/>
`,
})
export class Demo {
protected readonly items: OgeAccordionItemData[] = [
{ key: 'a', title: 'First' },
{ key: 'b', title: 'Second' },
];
}