什麼是 Angular Aria?
建立無障礙元件看似簡單,但要按照 W3C 無障礙指南 實作,需要投入相當多的心力,也需要無障礙方面的專業知識。
Angular Aria 是一組無頭(headless)的無障礙指令,實作了常見的 WAI-ARIA 模式。這些指令會處理鍵盤互動、ARIA 屬性、焦點管理與螢幕閱讀器支援。你只需要提供 HTML 結構、CSS 樣式與商業邏輯!
安裝
npm
npm install @angular/aria
yarn
yarn add @angular/aria
pnpm
pnpm add @angular/aria
bun
bun add @angular/aria
展示
以工具列選單為例。它看起來可能只是「一排綁了特定邏輯的按鈕」,但對不熟悉無障礙的人來說,鍵盤導覽與螢幕閱讀器會帶來許多意料之外的複雜度。
app.ts
import {Component, signal} from '@angular/core';
import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Toolbar, ToolbarWidget, ToolbarWidgetGroup],
})
export class App {
readonly bold = signal(false);
readonly italic = signal(false);
readonly underlined = signal(false);
readonly alignment = signal<'left' | 'center' | 'right'>('left');
}
app.html
<div ngToolbar aria-label="Text Formatting Tools">
<div class="group">
<button
ngToolbarWidget
value="undo"
type="button"
aria-label="undo"
class="material-symbols-outlined"
translate="no"
>
undo
</button>
<button
ngToolbarWidget
value="redo"
type="button"
aria-label="redo"
class="material-symbols-outlined"
translate="no"
>
redo
</button>
</div>
<div class="separator" role="separator"></div>
<div class="group">
<button
ngToolbarWidget
value="bold"
type="button"
aria-label="bold"
class="material-symbols-outlined"
[attr.aria-pressed]="bold()"
(click)="bold.set(!bold())"
translate="no"
>
format_bold
</button>
<button
ngToolbarWidget
value="italic"
type="button"
aria-label="italic"
class="material-symbols-outlined"
[attr.aria-pressed]="italic()"
(click)="italic.set(!italic())"
translate="no"
>
format_italic
</button>
<button
ngToolbarWidget
value="underlined"
type="button"
aria-label="underlined"
class="material-symbols-outlined"
[attr.aria-pressed]="underlined()"
(click)="underlined.set(!underlined())"
translate="no"
>
format_underlined
</button>
</div>
<div class="separator" role="separator"></div>
<div ngToolbarWidgetGroup role="radiogroup" class="group" aria-label="Text alignment options">
<button
ngToolbarWidget
role="radio"
type="button"
value="align left"
aria-label="align left"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'left'"
(click)="alignment.set('left')"
translate="no"
>
format_align_left
</button>
<button
ngToolbarWidget
role="radio"
type="button"
value="align center"
aria-label="align center"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'center'"
(click)="alignment.set('center')"
translate="no"
>
format_align_center
</button>
<button
ngToolbarWidget
role="radio"
type="button"
value="align right"
aria-label="align right"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'right'"
(click)="alignment.set('right')"
translate="no"
>
format_align_right
</button>
</div>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
}
[ngToolbar] {
gap: 1.5rem;
display: flex;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background-color: var(--septenary-contrast);
}
.group {
gap: 0.5rem;
display: flex;
}
.separator {
width: 1px;
align-self: center;
height: calc(100% - 1rem);
background-color: var(--quinary-contrast);
}
[ngToolbarWidget] {
border: none;
cursor: pointer;
padding: 0.5rem;
border-radius: 4px;
font-size: 1.25rem;
background-color: transparent;
color: var(--primary-contrast);
}
[ngToolbarWidget]:hover {
background-color: color-mix(in srgb, var(--primary-contrast) 10%, transparent);
}
[ngToolbarWidget]:active {
background-color: color-mix(in srgb, var(--primary-contrast) 15%, transparent);
}
[ngToolbarWidget]:focus {
outline-offset: -1px;
outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
[ngToolbarWidget][aria-pressed="true"],
[ngToolbarWidget][aria-checked="true"] {
color: color-mix(in srgb, var(--hot-pink) 80%, var(--primary-contrast));
background-color: color-mix(in srgb, var(--hot-pink) 10%, transparent);
}
app.ts
import {Component, signal} from '@angular/core';
import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Toolbar, ToolbarWidget, ToolbarWidgetGroup],
})
export class App {
readonly bold = signal(false);
readonly italic = signal(false);
readonly underlined = signal(false);
readonly alignment = signal<'left' | 'center' | 'right'>('left');
}
app.html
<div ngToolbar class="material-toolbar" aria-label="Text Formatting Tools">
<div class="group">
<button
ngToolbarWidget
value="undo"
type="button"
aria-label="undo"
class="material-symbols-outlined"
translate="no"
>
undo
</button>
<button
ngToolbarWidget
value="redo"
type="button"
aria-label="redo"
class="material-symbols-outlined"
translate="no"
>
redo
</button>
</div>
<div class="separator" role="separator"></div>
<div class="group">
<button
ngToolbarWidget
value="bold"
type="button"
aria-label="bold"
class="material-symbols-outlined"
[attr.aria-pressed]="bold()"
(click)="bold.set(!bold())"
translate="no"
>
format_bold
</button>
<button
ngToolbarWidget
value="italic"
type="button"
aria-label="italic"
class="material-symbols-outlined"
[attr.aria-pressed]="italic()"
(click)="italic.set(!italic())"
translate="no"
>
format_italic
</button>
<button
ngToolbarWidget
value="underlined"
type="button"
aria-label="underlined"
class="material-symbols-outlined"
[attr.aria-pressed]="underlined()"
(click)="underlined.set(!underlined())"
translate="no"
>
format_underlined
</button>
</div>
<div class="separator" role="separator"></div>
<div ngToolbarWidgetGroup role="radiogroup" class="group" aria-label="Text alignment options">
<button
ngToolbarWidget
role="radio"
type="button"
value="align left"
aria-label="align left"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'left'"
(click)="alignment.set('left')"
translate="no"
>
format_align_left
</button>
<button
ngToolbarWidget
role="radio"
type="button"
value="align center"
aria-label="align center"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'center'"
(click)="alignment.set('center')"
translate="no"
>
format_align_center
</button>
<button
ngToolbarWidget
role="radio"
type="button"
value="align right"
aria-label="align right"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'right'"
(click)="alignment.set('right')"
translate="no"
>
format_align_right
</button>
</div>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
}
[ngToolbar] {
display: flex;
}
[ngToolbarWidget] {
border: none;
outline: none;
cursor: pointer;
width: 3rem;
height: 3rem;
font-size: 1.25rem;
border-radius: 2rem;
color: color-mix(in srgb, var(--vivid-pink) 70%, var(--full-contrast));
background-color: color-mix(in srgb, var(--vivid-pink) 20%, transparent);
transition:
width 0.2s ease-in-out,
background-color 0.15s ease-in-out,
color 0.15s ease-in-out;
}
[ngToolbarWidget]:focus {
outline-offset: 2px;
outline: 2px solid var(--vivid-pink);
}
[ngToolbarWidget]:active,
[ngToolbarWidget][aria-pressed='true'],
[ngToolbarWidget][aria-checked='true'] {
background-color: color-mix(in srgb, var(--vivid-pink) 80%, var(--full-contrast));
color: var(--page-background);
}
[ngToolbarWidget][aria-checked='true'] {
width: 4.5rem;
}
.group {
display: flex;
gap: 0.5rem;
}
.separator {
width: 1px;
margin: 0 1rem;
height: calc(100% - 2rem);
background-color: var(--quinary-contrast);
}
app.ts
import {Component, signal} from '@angular/core';
import {Toolbar, ToolbarWidget, ToolbarWidgetGroup} from '@angular/aria/toolbar';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Toolbar, ToolbarWidget, ToolbarWidgetGroup],
})
export class App {
readonly bold = signal(false);
readonly italic = signal(false);
readonly underlined = signal(false);
readonly alignment = signal<'left' | 'center' | 'right'>('left');
}
app.html
<div ngToolbar class="retro-toolbar" aria-label="Text Formatting Tools">
<div class="group">
<button
ngToolbarWidget
value="undo"
type="button"
aria-label="undo"
class="material-symbols-outlined"
translate="no"
>
undo
</button>
<button
ngToolbarWidget
value="redo"
type="button"
aria-label="redo"
class="material-symbols-outlined"
translate="no"
>
redo
</button>
</div>
<div class="separator" role="separator"></div>
<div class="group">
<button
ngToolbarWidget
value="bold"
type="button"
aria-label="bold"
class="material-symbols-outlined"
[attr.aria-pressed]="bold()"
(click)="bold.set(!bold())"
translate="no"
>
format_bold
</button>
<button
ngToolbarWidget
value="italic"
type="button"
aria-label="italic"
class="material-symbols-outlined"
[attr.aria-pressed]="italic()"
(click)="italic.set(!italic())"
translate="no"
>
format_italic
</button>
<button
ngToolbarWidget
value="underlined"
type="button"
aria-label="underlined"
class="material-symbols-outlined"
[attr.aria-pressed]="underlined()"
(click)="underlined.set(!underlined())"
translate="no"
>
format_underlined
</button>
</div>
<div class="separator" role="separator"></div>
<div ngToolbarWidgetGroup role="radiogroup" class="group" aria-label="Text alignment options">
<button
ngToolbarWidget
role="radio"
type="button"
value="align left"
aria-label="align left"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'left'"
(click)="alignment.set('left')"
translate="no"
>
format_align_left
</button>
<button
ngToolbarWidget
role="radio"
type="button"
value="align center"
aria-label="align center"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'center'"
(click)="alignment.set('center')"
translate="no"
>
format_align_center
</button>
<button
ngToolbarWidget
role="radio"
type="button"
value="align right"
aria-label="align right"
class="material-symbols-outlined"
[attr.aria-checked]="alignment() === 'right'"
(click)="alignment.set('right')"
translate="no"
>
format_align_right
</button>
</div>
</div>
app.css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--vivid-pink) 80%, var(--page-background));
--retro-pressed-tint: #fbfbfb;
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
:host-context(.docs-dark-mode) {
--retro-pressed-tint: #151417;
}
[ngToolbar] {
gap: 1.5rem;
display: flex;
padding: 1rem;
}
.group {
gap: 1rem;
display: flex;
}
.separator {
width: 1px;
align-self: center;
height: calc(100% - 1rem);
background-color: var(--quinary-contrast);
}
[ngToolbarWidget] {
border: none;
outline: none;
cursor: pointer;
padding: 0.5rem;
font-size: 1.25rem;
color: var(--page-background);
background-color: var(--retro-button-color);
box-shadow: var(--retro-clickable-shadow);
transition:
transform 0.1s,
box-shadow 0.1s;
}
[ngToolbarWidget]:focus,
[ngToolbarWidget]:hover {
transform: translate(1px, 1px);
}
[ngToolbarWidget]:active,
[ngToolbarWidget][aria-pressed='true'],
[ngToolbarWidget][aria-checked='true'] {
transform: translate(4px, 4px);
box-shadow: var(--retro-pressed-shadow);
background-color: color-mix(in srgb, var(--retro-button-color) 60%, var(--retro-pressed-tint));
}
[ngToolbarWidget]:focus {
outline-offset: 4px;
outline: 4px dashed var(--retro-button-color);
}
光是這一個情境,開發者就需要考慮:
- 鍵盤導覽:使用者需要能用 Enter 或 Space 開啟選單、用方向鍵導覽選項、用 Enter 選取,並用 Escape 關閉。
- 螢幕閱讀器:需要播報選單的狀態、選項數量,以及目前取得焦點的選項。
- 焦點管理:需要在觸發器與選單項目之間合乎邏輯地移動。
- 由右至左的語言:需要能以反方向導覽。
包含哪些內容?
Angular Aria 針對常見的互動模式,提供附有完整文件、可運作範例與 API 參考的指令:
搜尋與選取
| 元件 | 說明 |
|---|---|
| Autocomplete | 文字輸入框,使用者輸入時會出現篩選後的建議 |
| Listbox | 支援鍵盤導覽的單選或多選選項清單 |
| Select | 支援鍵盤導覽的單選下拉模式 |
| Multiselect | 以精簡方式顯示的多選下拉模式 |
| Combobox | 基礎指令,負責協調文字輸入框與彈出視窗 |
導覽與呼叫動作
內容組織
| 元件 | 說明 |
|---|---|
| Accordion | 可收合的內容面板,可個別展開,也可一次只展開一個 |
| Tabs | 分頁介面,支援自動或手動啟用模式 |
| Tree | 階層式清單,支援展開/收合功能 |
| Grid | 二維資料顯示,支援逐格鍵盤導覽 |
何時使用 Angular Aria
當你需要符合 WCAG 且可自訂樣式的無障礙互動元件時,Angular Aria 是很好的選擇。例如:
- 建立設計系統:你的團隊維護一套有特定視覺標準的元件庫,需要無障礙的實作
- 企業級元件庫:你正在為組織內多個應用程式建立可重複使用的元件
- 自訂品牌需求:介面需要符合精確的設計規格,是預先套好樣式的元件庫難以配合的
何時不該使用 Angular Aria
Angular Aria 不見得適合所有情境:
- 預先套好樣式的元件:如果你需要不加自訂樣式就外觀完整的元件,請改用 Angular Material
- 簡單表單:在簡單的使用情境下,原生 HTML 表單控制項(例如
<button>與<input type="radio">)本身就具備無障礙支援 - 快速製作原型:在快速驗證概念時,預先套好樣式的元件庫能減少初期開發時間
下一步
從側邊導覽或上方清單挑一個元件來看看,或者從 Toolbar 開始,看看 Angular Aria 指令如何運作的完整範例!