آنلاین

Angular Aria

Angular Aria چیست؟

ساخت کامپوننت‌های accessible در نگاه اول ساده به نظر می‌رسد، اما پیاده‌سازی آن‌ها مطابق راهنماهای Accessibility در W3C به تلاش قابل توجه و تخصص accessibility نیاز دارد.

Angular Aria مجموعه‌ای از directiveهای headless و accessible است که patternهای رایج WAI-ARIA را پیاده‌سازی می‌کنند. این directiveها keyboard interactionها، attributeهای ARIA، مدیریت focus و پشتیبانی از screen reader را مدیریت می‌کنند. تنها کاری که شما باید انجام دهید فراهم کردن ساختار HTML، styling با CSS و business logic است!

نصب

shell
npm install @angular/aria
shell
yarn add @angular/aria
shell
pnpm add @angular/aria
shell
bun add @angular/aria

Showcase

برای مثال، یک toolbar menu را در نظر بگیریم. با اینکه ممکن است در ظاهر فقط یک ردیف «ساده» از buttonها با logic مشخص باشد، keyboard navigation و screen readerها برای کسانی که با accessibility آشنا نیستند پیچیدگی‌های غیرمنتظره زیادی اضافه می‌کنند.

ts
import {Component} 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 {}
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"
      #bold="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-pressed]="bold.selected()"
      translate="no"
    >
      format_bold
    </button>

    <button
      ngToolbarWidget
      value="italic"
      type="button"
      aria-label="italic"
      #italic="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-pressed]="italic.selected()"
      translate="no"
    >
      format_italic
    </button>

    <button
      ngToolbarWidget
      value="underlined"
      type="button"
      aria-label="underlined"
      #underlined="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-pressed]="underlined.selected()"
      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"
      #leftAlign="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-checked]="leftAlign.selected()"
      translate="no"
    >
      format_align_left
    </button>

    <button
      ngToolbarWidget
      role="radio"
      type="button"
      value="align center"
      aria-label="align center"
      #centerAlign="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-checked]="centerAlign.selected()"
      translate="no"
    >
      format_align_center
    </button>

    <button
      ngToolbarWidget
      role="radio"
      type="button"
      value="align right"
      aria-label="align right"
      #rightAlign="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-checked]="rightAlign.selected()"
      translate="no"
    >
      format_align_right
    </button>
  </div>
</div>
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);
}
ts
import {Component} 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 {}
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"
      #bold="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-pressed]="bold.selected()"
      translate="no"
    >
      format_bold
    </button>

    <button
      ngToolbarWidget
      value="italic"
      type="button"
      aria-label="italic"
      #italic="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-pressed]="italic.selected()"
      translate="no"
    >
      format_italic
    </button>

    <button
      ngToolbarWidget
      value="underlined"
      type="button"
      aria-label="underlined"
      #underlined="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-pressed]="underlined.selected()"
      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"
      #leftAlign="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-checked]="leftAlign.selected()"
      translate="no"
    >
      format_align_left
    </button>

    <button
      ngToolbarWidget
      role="radio"
      type="button"
      value="align center"
      aria-label="align center"
      #centerAlign="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-checked]="centerAlign.selected()"
      translate="no"
    >
      format_align_center
    </button>

    <button
      ngToolbarWidget
      role="radio"
      type="button"
      value="align right"
      aria-label="align right"
      #rightAlign="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-checked]="rightAlign.selected()"
      translate="no"
    >
      format_align_right
    </button>
  </div>
</div>
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);
}
ts
import {Component} 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 {}
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"
      #bold="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-pressed]="bold.selected()"
      translate="no"
    >
      format_bold
    </button>

    <button
      ngToolbarWidget
      value="italic"
      type="button"
      aria-label="italic"
      #italic="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-pressed]="italic.selected()"
      translate="no"
    >
      format_italic
    </button>

    <button
      ngToolbarWidget
      value="underlined"
      type="button"
      aria-label="underlined"
      #underlined="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-pressed]="underlined.selected()"
      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"
      #leftAlign="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-checked]="leftAlign.selected()"
      translate="no"
    >
      format_align_left
    </button>

    <button
      ngToolbarWidget
      role="radio"
      type="button"
      value="align center"
      aria-label="align center"
      #centerAlign="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-checked]="centerAlign.selected()"
      translate="no"
    >
      format_align_center
    </button>

    <button
      ngToolbarWidget
      role="radio"
      type="button"
      value="align right"
      aria-label="align right"
      #rightAlign="ngToolbarWidget"
      class="material-symbols-outlined"
      [aria-checked]="rightAlign.selected()"
      translate="no"
    >
      format_align_right
    </button>
  </div>
</div>
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-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);
}

[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(--gray-50));
}

[ngToolbarWidget]:focus {
  outline-offset: 4px;
  outline: 4px dashed var(--retro-button-color);
}

در همین یک سناریو، developerها باید این موارد را در نظر بگیرند:

  • Keyboard navigation. کاربران باید بتوانند menu را با Enter یا Space باز کنند، با arrow keyها بین optionها حرکت کنند، با Enter انتخاب کنند و با Escape ببندند.
  • Screen readerها باید state مربوط به menu، تعداد optionها و اینکه کدام option focus دارد را announce کنند.
  • Focus management باید focus را منطقی بین trigger و menu itemها جابه‌جا کند.
  • زبان‌های راست‌به‌چپ به قابلیت navigate کردن در جهت معکوس نیاز دارند.

چه چیزهایی شامل می‌شود؟

Angular Aria شامل directiveهایی با مستندات جامع، مثال‌های کارکرده و API reference برای patternهای تعاملی رایج است:

جستجو و انتخاب

کامپوننتتوضیح
AutocompleteText input همراه با suggestionهای فیلترشده که هنگام typing کاربر ظاهر می‌شوند
Listboxلیست‌های option با single-select یا multi-select و keyboard navigation
Selectpattern مربوط به dropdown تک‌انتخابی با keyboard navigation
Multiselectpattern مربوط به dropdown چندانتخابی با نمایش compact
Comboboxdirective primitive که یک text input را با popup هماهنگ می‌کند
کامپوننتتوضیح
Menudropdown menuها با submenuهای nested و keyboard shortcutها
Menubarnavigation bar افقی برای menuهای persistent برنامه
Toolbarمجموعه‌های گروه‌بندی‌شده از controlها با keyboard navigation منطقی

سازماندهی محتوا

کامپوننتتوضیح
Accordionpanelهای محتوایی collapsible که می‌توانند جداگانه یا انحصاری expand شوند
Tabsinterfaceهای tabدار با modeهای activation خودکار یا دستی
Treeلیست‌های سلسله‌مراتبی با قابلیت expand/collapse
Gridنمایش دوبعدی data با keyboard navigation سلول‌به‌سلول

چه زمانی از Angular Aria استفاده کنیم

Angular Aria زمانی مناسب است که به کامپوننت‌های تعاملی accessible نیاز دارید که با WCAG سازگار باشند و styling سفارشی داشته باشند. مثال‌ها:

  • ساخت design system - تیم شما یک component library با استانداردهای visual مشخص نگه می‌دارد که به implementationهای accessible نیاز دارد.
  • Enterprise component libraryها - در حال ساخت کامپوننت‌های reusable برای چند برنامه درون یک سازمان هستید.
  • نیازهای brand سفارشی - interface باید با design specificationهای دقیق match شود که component libraryهای pre-styled به سادگی نمی‌توانند فراهم کنند.

چه زمانی از Angular Aria استفاده نکنیم

Angular Aria ممکن است برای هر سناریویی مناسب نباشد:

  • کامپوننت‌های pre-styled - اگر به کامپوننت‌هایی نیاز دارید که بدون styling سفارشی کامل به نظر برسند، به جای آن از Angular Material استفاده کنید.
  • Formهای ساده - controlهای native HTML مثل <button> و <input type="radio"> برای use caseهای ساده accessibility داخلی فراهم می‌کنند.
  • Rapid prototyping - وقتی conceptها را سریع validate می‌کنید، component libraryهای pre-styled زمان development اولیه را کاهش می‌دهند.

قدم‌های بعدی

از side nav یا فهرست بالا یک کامپوننت را بررسی کنید، یا با Toolbar شروع کنید تا یک مثال کامل از نحوه کار directiveهای Angular Aria ببینید!