Angular Aria (@angular/aria, apărut în v21) e o colecție de directive headless: comportament complet, accesibilitate (roluri ARIA, tastatură, focus) și zero stiluri. E fundația ideală pentru propriul design system: tu scrii CSS-ul, Angular se ocupă de partea grea — exact lucrul pe care aproape toată lumea îl face greșit când își scrie singură tab-uri sau dropdown-uri.
npm i @angular/aria
# Pattern-uri: Accordion, Combobox (autocomplete/select), Grid, Listbox, Menu, Tabs, Toolbar, TreeTabs
import { Tab, TabContent, TabList, TabPanel, Tabs } from '@angular/aria/tabs';
@Component({
imports: [Tabs, TabList, Tab, TabPanel, TabContent],
template: `
<div ngTabs>
<ul ngTabList [(selectedTab)]="selected">
<li ngTab value="profil">Profil</li>
<li ngTab value="securitate">Securitate</li>
</ul>
<div ngTabPanel value="profil"><ng-template ngTabContent>…</ng-template></div>
<div ngTabPanel value="securitate"><ng-template ngTabContent>…</ng-template></div>
</div>
`,
styles: `
/* Stilizezi după atributele ARIA pe care le setează directivele */
[ngTab][aria-selected='true'] { border-bottom: 2px solid var(--accent); }
`,
})
export class SettingsTabs {
readonly selected = signal<string | undefined>('profil');
}- Profil
- Securitate
- Notificări
selectedTab (model): profil
Accordion
<div ngAccordionGroup [multiExpandable]="false">
<button ngAccordionTrigger [panel]="p1">Întrebarea 1</button>
<div ngAccordionPanel #p1="ngAccordionPanel">
<ng-template ngAccordionContent>Răspuns randat lazy, la deschidere.</ng-template>
</div>
</div>Listbox
<!-- selectionMode="explicit": click / Space comută selecția (altfel selecția urmează focusul) -->
<ul ngListbox [multi]="true" selectionMode="explicit" [(value)]="toppings" aria-label="Toppinguri">
@for (t of options; track t) {
<li ngOption [value]="t" [label]="t">{{ t }}</li>
}
</ul>- ✓Mozzarella
- ✓Ciuperci
- ✓Măsline
- ✓Ardei
- ✓Ananas
value (model): mozzarella · tastatură: ↑ ↓, Space, Ctrl+A, tastează o literă
Menu: meniu cu submeniu
Un buton cu ngMenuTrigger deschide un ngMenu; fiecare opțiune e un ngMenuItem, iar un item cu [submenu] deschide alt meniu. Primești tastatura completă (săgeți, Enter, Escape, typeahead) și rolurile menu / menuitem. Alegerea vine prin output-ul itemSelected, iar cu ng-template ngMenuContent conținutul e randat abia când meniul se deschide.
<!-- meniul se deschide din buton; „Distribuie” deschide un submeniu -->
<button ngMenuTrigger [menu]="actions">Acțiuni</button>
<div ngMenu #actions="ngMenu" (itemSelected)="run($event)">
<ng-template ngMenuContent>
<div ngMenuItem value="edit">Editează</div>
<div ngMenuItem value="duplicate">Duplică</div>
<div ngMenuItem value="share" [submenu]="share">Distribuie</div>
</ng-template>
</div>
<div ngMenu #share="ngMenu" (itemSelected)="run($event)">
<div ngMenuItem value="link">Copiază linkul</div>
<div ngMenuItem value="email">Email</div>
</div>Toolbar: un singur Tab pentru un grup de butoane
Într-o bară de unelte, Tab ar trebui să intre o dată în grup, iar săgețile să mute focusul între butoane (așa-numitul roving tabindex). ngToolbar face exact asta: tu marchezi fiecare buton cu ngToolbarWidget, iar directiva gestionează tabindex, orientarea și butoanele dezactivate.
<!-- Tab intră o dată în bară; ← → mută focusul între butoane -->
<div ngToolbar aria-label="Formatare">
<button ngToolbarWidget (click)="bold()">B</button>
<button ngToolbarWidget (click)="italic()">I</button>
<button ngToolbarWidget [disabled]="!canUndo()" (click)="undo()">↶</button>
</div>Cum stilizezi: atributele ARIA sunt API-ul
Directivele nu adaugă clase, ci setează atributele ARIA corecte: aria-selected, aria-expanded, aria-disabled. Stilizează după ele, nu după clase puse de mână. Așa aspectul nu poate rămâne în urma stării: dacă cititorul de ecran aude „selectat”, utilizatorul vede și el „selectat”.
/* Starea vine din atributele puse de directive, nu din clase proprii */
[ngTab][aria-selected='true'] {
border-bottom: 2px solid var(--accent);
}
[ngAccordionTrigger][aria-expanded='true'] .chevron {
rotate: 180deg;
}
[ngOption][aria-selected='true']::before {
content: '✓ ';
}
[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}Testare cu harness-uri
Fiecare pattern are un harness în @angular/aria/<pattern>/testing. Testul vorbește în termenii utilizatorului („selectează tab-ul Securitate”), nu în selectori CSS, deci nu se strică atunci când schimbi markup-ul.
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { TabsHarness } from '@angular/aria/tabs/testing';
it('selectează tab-ul după titlu', async () => {
const fixture = TestBed.createComponent(SettingsPage);
const loader = TestbedHarnessEnvironment.loader(fixture);
const tabs = await loader.getHarness(TabsHarness);
await tabs.selectTab({ title: 'Securitate' });
const selected = await tabs.getSelectedTab();
expect(await selected?.getTitle()).toBe('Securitate');
});Aria, CDK sau Material?
| Angular Aria | Angular CDK | Angular Material | |
|---|---|---|---|
| Ce e | pattern-uri UI accesibile, headless | primitive de comportament (overlay, drag, a11y, scrolling) | componente complete, cu stil Material |
| Stil | zero | zero | Material Design (personalizabil cu token-uri) |
| Când | design system propriu | blocuri de construcție low-level | vrei UI gata făcut, rapid |
Un control construit peste Aria se leagă de formulare ca la Controale custom de formular; stilizarea prin atribute ARIA folosește regulile de la Stilurile componentelor și încapsularea.
Când DA, când NU
- Construiești componente proprii (design system) și vrei accesibilitate corectă din prima.
- Ai tabs / menu / combobox scrise de mână, fără suport de tastatură.
- Ai nevoie de control total asupra markup-ului și stilului.
- Folosești deja Material / PrimeNG / altă librărie completă — acolo comportamentul e inclus.
- Ai nevoie doar de un overlay sau drag & drop → CDK.
Capcane frecvente
- Listă sau grup de tab-uri fără nume: dacă nu există un titlu vizibil, pune
aria-label. Altfel cititorul de ecran anunță doar „listă” sau „tab list”. - Clase de stare puse de mână în paralel cu starea Aria (
[class.active]): la prima neatenție nu mai corespund. Stilizează dupăaria-*. (click)propriu pe item-uri: dublezi comportamentul și pierzi tastatura. Ascultă output-ul directivei (itemSelected) sau leagăvalue.- Un
<select>nativ e uneori suficient: e accesibil gratuit și merge perfect pe mobil. Aria se justifică atunci când ai nevoie de un aspect sau de un comportament pe care elementul nativ nu le poate oferi.
Fișă rapidă
@angular/aria/tabs ngTabs ngTabList ngTab ngTabPanel
@angular/aria/accordion ngAccordionGroup ngAccordionTrigger ngAccordionPanel
@angular/aria/listbox ngListbox ngOption
@angular/aria/menu ngMenuTrigger ngMenu ngMenuItem ngMenuBar ngMenuContent
@angular/aria/toolbar ngToolbar ngToolbarWidget ngToolbarWidgetGroup
@angular/aria/combobox ngCombobox ngComboboxWidget
@angular/aria/tree ngTree ngTreeItem
@angular/aria/grid ngGrid ngGridRow ngGridCell ngGridCellWidget
harness-uri de test: @angular/aria/<pattern>/testing (ex. TabsHarness, MenuHarness)