آنلاین

Grid

Overview

Grid به کاربران اجازه می‌دهد data یا elementهای تعاملی دوبعدی را با directional arrow keyها، Home، End و Page Up/Down navigate کنند. Gridها برای data tableها، calendarها، spreadsheetها و layout patternهایی مناسب‌اند که elementهای تعاملی مرتبط را گروه‌بندی می‌کنند.

ts
import {Component} from '@angular/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

interface Cell {
  rowSpan: number;
  colSpan: number;
  emoji: string;
  explode: boolean;
}

const bomb = '💣';
const emojis = ['🥳', '🤩', '🎉', '🚀', '🔥', '💯', '🦄', '🤯', '💖', '✨', bomb];

function randomSpan(): number {
  const spanChanceTable = [...Array(10).fill(1), ...Array(4).fill(2), ...Array(1).fill(3)];
  const randomIndex = Math.floor(Math.random() * spanChanceTable.length);
  return spanChanceTable[randomIndex];
}

function generateValidGrid(rowCount: number, colCount: number): Cell[][] {
  const grid: Cell[][] = [];
  const visitedCoords = new Set<string>();
  for (let r = 0; r < rowCount; r++) {
    const row = [];
    for (let c = 0; c < colCount; c++) {
      if (visitedCoords.has(`${r},${c}`)) {
        continue;
      }

      const rowSpan = Math.min(randomSpan(), rowCount - r);
      const maxColSpan = Math.min(randomSpan(), colCount - c);
      let colSpan = 1;
      while (colSpan < maxColSpan) {
        if (visitedCoords.has(`${r},${c + colSpan}`)) break;
        colSpan += 1;
      }

      const emoji = emojis[Math.floor(Math.random() * emojis.length)];

      row.push({
        rowSpan,
        colSpan,
        emoji,
        explode: emoji === bomb,
      });

      for (let rs = 0; rs < rowSpan; rs++) {
        for (let cs = 0; cs < colSpan; cs++) {
          visitedCoords.add(`${r + rs},${c + cs}`);
        }
      }
    }
    grid.push(row);
  }
  return grid;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
  readonly gridData: Cell[][] = generateValidGrid(6, 6);
}
html
<table ngGrid #grid="ngGrid">
  @for (row of gridData; track row) {
    <tr ngGridRow>
      @for (cell of row; track cell) {
        @let flipped = {value: false};
        <td ngGridCell [rowSpan]="cell.rowSpan" [colSpan]="cell.colSpan">
          <button
            ngGridCellWidget
            class="card"
            [class.flipped]="flipped.value"
            (click)="flipped.value = true"
          >
            <div class="card-face card-front">
              <svg viewBox="0 0 222 245" xmlns="http://www.w3.org/2000/svg" class="angular-logo">
                <path class="shield-shape" />
              </svg>
            </div>

            <div class="card-face card-back">
              <div [class.explode]="flipped.value && cell.explode">{{ cell.emoji }}</div>
            </div>
          </button>
        </td>
      }
    </tr>
  }
</table>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);

  --card-shadow: 2px 4px 6px rgba(0, 0, 0, 0.5);
}

button {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
}

[ngGrid] {
  display: table;
  border-spacing: 0.75rem;
}

[ngGridCell] {
  height: 4rem;
  width: 4rem;
  perspective: 1000px;
}

.card {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  position: relative;
  transform-style: preserve-3d;
  transition: transform 0.3s ease-in-out;
  cursor: pointer;
  border-radius: 0.5rem;
  border: 0.25rem solid #f0f0f0;
  box-shadow: var(--card-shadow);
}

.card.flipped {
  transform: rotateY(180deg);
  cursor: default;
}

.card:not(.flipped):hover,
.card:not(.flipped):focus {
  transform: scale(1.05) translate(-2px, -2px);
}

.card:hover,
.card:focus {
  outline-offset: 2px;
  outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}

.card-face {
  position: absolute;
  width: 100%;
  height: 100%;
  backface-visibility: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2rem;
  border-radius: 0.25rem;
}

.card-front {
  background-image: var(--hot-pink-to-electric-violet-radial-gradient);
  color: rgba(0, 0, 0, 0.6);
  overflow: hidden;
}

.card-back {
  background: #f0f0f0;
  transform: rotateY(180deg);
}

.explode {
  animation: shake 0.25s 20 linear;
}

@keyframes shake {
  0%,
  100% {
    transform: translate(0, 0) rotate(0deg);
  }
  20% {
    transform: translate(-3px, -1px) rotate(-1deg);
  }
  40% {
    transform: translate(3px, 1px) rotate(1deg);
  }
  60% {
    transform: translate(-3px, 1px) rotate(-1deg);
  }
  80% {
    transform: translate(3px, -1px) rotate(1deg);
  }
}

.angular-logo {
  transform: rotate(-25deg) scale(1.1) translateY(5%);
}

.shield-shape {
  d: path(
    'm 222.077 39.192 l -8.019 125.923 L 137.387 0 l 84.69 39.192 Z m -53.105 162.825 l -57.933 33.056 l -57.934 -33.056 l 11.783 -28.556 h 92.301 l 11.783 28.556 Z M 111.039 62.675 l 30.357 73.803 H 80.681 l 30.358 -73.803 Z M 7.937 165.115 L 0 39.192 L 84.69 0 L 7.937 165.115 Z'
  );
  fill: currentColor;
}

Usage

Gridها برای data یا elementهای تعاملی‌ای مناسب‌اند که در row و column سازماندهی شده‌اند و کاربران به keyboard navigation در چند جهت نیاز دارند.

از grid استفاده کنید وقتی:

  • data tableهای تعاملی با cellهای editable یا selectable می‌سازید.
  • calendar یا date picker ایجاد می‌کنید.
  • interfaceهایی شبیه spreadsheet پیاده‌سازی می‌کنید.
  • elementهای تعاملی \(buttonها، checkboxها\) را گروه‌بندی می‌کنید تا tab stopها در صفحه کمتر شوند.
  • interfaceهایی می‌سازید که به keyboard navigation دوبعدی نیاز دارند.

از grid پرهیز کنید وقتی:

  • tableهای ساده read-only نمایش می‌دهید \(به جای آن از HTML semantic یعنی <table> استفاده کنید\).
  • listهای تک‌ستونه نمایش می‌دهید \(به جای آن از Listbox استفاده کنید\).
  • data سلسله‌مراتبی نمایش می‌دهید \(به جای آن از Tree استفاده کنید\).
  • formهایی بدون layout جدولی می‌سازید \(از form controlهای استاندارد استفاده کنید\).

قابلیت‌ها

  • Two-dimensional navigation - arrow keyها بین cellها در همه جهت‌ها حرکت می‌کنند.
  • Focus modeها - بین strategyهای focus مربوط به roving tabindex یا activedescendant انتخاب کنید.
  • Selection support - cell selection اختیاری با modeهای single یا multi-select.
  • Wrapping behavior - configure کنید navigation در edgeهای grid چطور wrap شود \(continuous، loop یا nowrap\).
  • Range selection - چند cell را با modifier keyها یا dragging انتخاب کنید.
  • Disabled stateها - کل grid یا cellهای جداگانه را disabled کنید.
  • RTL support - navigation خودکار برای زبان‌های راست‌به‌چپ.

مثال‌ها

Data table grid

برای tableهای تعاملی که کاربران باید با arrow keyها بین cellها navigate کنند، از grid استفاده کنید. این مثال یک data table پایه همراه با keyboard navigation را نشان می‌دهد.

ts
import {
  afterRenderEffect,
  Component,
  computed,
  ElementRef,
  signal,
  viewChild,
  WritableSignal,
} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

type Priority = 'High' | 'Medium' | 'Low';

interface Task {
  taskId: number;
  summary: string;
  priority: Priority;
  assignee: string;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget, FormsModule],
})
export class App {
  private readonly _headerCheckbox = viewChild<ElementRef<HTMLInputElement>>('headerCheckbox');

  readonly allSelected = computed(() => this.data().every((t) => t.selected()));
  readonly partiallySelected = computed(
    () => !this.allSelected() && this.data().some((t) => t.selected()),
  );
  readonly data = signal<(Task & {selected: WritableSignal<boolean>})[]>([
    {
      selected: signal(false),
      taskId: 101,
      summary: 'Create Grid Aria Pattern',
      priority: 'High',
      assignee: 'Cyber Cat',
    },
    {
      selected: signal(false),
      taskId: 102,
      summary: 'Build a Pill List example',
      priority: 'Medium',
      assignee: 'Caffeinated Owl',
    },
    {
      selected: signal(false),
      taskId: 103,
      summary: 'Build a Calendar example',
      priority: 'Medium',
      assignee: 'Copybara',
    },
    {
      selected: signal(false),
      taskId: 104,
      summary: 'Build a Data Table example',
      priority: 'Low',
      assignee: 'Rubber Duck',
    },
    {
      selected: signal(false),
      taskId: 105,
      summary: 'Explore Grid possibilities',
      priority: 'High',
      assignee: '[Your Name Here]',
    },
  ]);

  sortAscending: boolean = true;
  tempInput: string = '';

  constructor() {
    afterRenderEffect(() => {
      this._headerCheckbox()!.nativeElement.indeterminate = this.partiallySelected();
    });
  }

  startEdit(
    event: KeyboardEvent | FocusEvent | undefined,
    task: Task,
    inputEl: HTMLInputElement,
  ): void {
    this.tempInput = task.assignee;
    inputEl.focus();

    if (!(event instanceof KeyboardEvent)) return;

    // Start editing with an alphanumeric character.
    if (event.key.length === 1) {
      this.tempInput = event.key;
    }
  }

  onClickEdit(widget: GridCellWidget, task: Task, inputEl: HTMLInputElement) {
    if (widget.isActivated()) return;

    widget.activate();
    setTimeout(() => this.startEdit(undefined, task, inputEl));
  }

  completeEdit(event: KeyboardEvent | FocusEvent | undefined, task: Task): void {
    if (!(event instanceof KeyboardEvent)) {
      return;
    }
    if (event.key === 'Enter') {
      task.assignee = this.tempInput;
    }
  }

  updateSelection(event: Event): void {
    const checked = (event.target as HTMLInputElement).checked;
    this.data().forEach((t) => t.selected.set(checked));
  }

  sortTaskById(): void {
    this.sortAscending = !this.sortAscending;
    if (this.sortAscending) {
      this.data.update((tasks) => tasks.sort((a, b) => a.taskId - b.taskId));
    } else {
      this.data.update((tasks) => tasks.sort((a, b) => b.taskId - a.taskId));
    }
  }
}
html
<table ngGrid class="basic-data-table">
  <thead>
    <tr ngGridRow>
      <th ngGridCell>
        <input
          ngGridCellWidget
          aria-label="Select all rows"
          type="checkbox"
          [checked]="allSelected()"
          (change)="updateSelection($event)"
          #headerCheckbox
        />
      </th>
      <th ngGridCell>
        <button
          ngGridCellWidget
          class="sort-button"
          aria-label="Sort by ID"
          (click)="sortTaskById()"
        >
          ID
          <span
            aria-hidden="true"
            class="material-symbols-outlined"
            translate="no"
            aria-hidden="true"
          >
            {{ sortAscending ? 'arrow_upward' : 'arrow_downward' }}
          </span>
        </button>
      </th>
      <th ngGridCell>Task</th>
      <th ngGridCell>Priority</th>
      <th ngGridCell>Assignee</th>
    </tr>
  </thead>
  <tbody>
    @for (task of data(); track task.taskId) {
      <tr ngGridRow>
        <td ngGridCell>
          <input
            ngGridCellWidget
            aria-label="Select row {{ $index + 1 }}"
            type="checkbox"
            [(ngModel)]="task.selected"
          />
        </td>
        <td ngGridCell>{{ task.taskId }}</td>
        <td ngGridCell>{{ task.summary }}</td>
        <td ngGridCell>{{ task.priority }}</td>
        <td ngGridCell class="assignee-cell">
          <div
            type="button"
            ngGridCellWidget
            aria-label="edit assignee"
            widgetType="editable"
            (activated)="startEdit($event, task, assigneeInput)"
            (deactivated)="completeEdit($event, task)"
            #widget="ngGridCellWidget"
          >
            <span [class.hidden]="widget.isActivated()">{{ task.assignee }}</span>
            <input
              [class.hidden]="!widget.isActivated()"
              class="assignee-edit-input"
              [(ngModel)]="tempInput"
              #assigneeInput
            />
            <button
              tabindex="-1"
              aria-label="edit assignee"
              class="material-symbols-outlined assignee-edit-button"
              (click)="onClickEdit(widget, task, assigneeInput)"
              [class.hidden]="widget.isActivated()"
              translate="no"
            >
              edit
            </button>
          </div>
        </td>
      </tr>
    }
  </tbody>
</table>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.hidden {
  display: none;
}

button {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
}

input[type='checkbox'] {
  accent-color: var(--electric-violet);
  transform: scale(1.3);
  outline: none;
  cursor: pointer;
}

[ngGrid] {
  display: table;
  background-color: var(--septenary-contrast);
  border-spacing: 0;
}

[ngGrid] th,
[ngGrid] td {
  padding: 0.75rem 1rem;
}

thead {
  background-image: var(--pink-to-purple-horizontal-gradient);
  background-clip: text;
  color: transparent;
}

tbody {
  background-color: var(--octonary-contrast);
}

tbody [ngGridRow]:focus-within,
tbody [ngGridRow]:hover {
  background-color: var(--septenary-contrast);
}

[ngGridCell]:focus-within,
[ngGridCell]:hover {
  outline-offset: -1px;
  outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}

.sort-button {
  display: flex;
  align-items: center;
  cursor: pointer;
  font-size: 1rem;
  font-weight: 700;
}

.assignee-cell [ngGridCellWidget] {
  display: flex;
  align-items: center;
  justify-content: space-between;
  outline: none;
}

.assignee-edit-button {
  visibility: hidden;
  cursor: pointer;
}

.assignee-cell:focus-within .assignee-edit-button,
.assignee-cell:hover .assignee-edit-button {
  visibility: initial;
}

.assignee-edit-input {
  outline: none;
  border: none;
  color: var(--full-contrast);
  background-color: var(--page-background);
  font-size: 1rem;
  padding: 0.5rem;
}
ts
import {
  afterRenderEffect,
  Component,
  computed,
  ElementRef,
  signal,
  viewChild,
  WritableSignal,
} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

type Rank = 'S' | 'A' | 'B' | 'C';

interface Task {
  reward: number;
  target: string;
  rank: Rank;
  hunter: string;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget, FormsModule],
})
export class App {
  private readonly _headerCheckbox = viewChild<ElementRef<HTMLInputElement>>('headerCheckbox');

  readonly allSelected = computed(() => this.data().every((t) => t.selected()));
  readonly partiallySelected = computed(
    () => !this.allSelected() && this.data().some((t) => t.selected()),
  );
  readonly data = signal<(Task & {selected: WritableSignal<boolean>})[]>([
    {
      selected: signal(false),
      reward: 50,
      target: '10 Goblins',
      rank: 'C',
      hunter: 'KB Smasher',
    },
    {
      selected: signal(false),
      reward: 999,
      target: '1 Dragon',
      rank: 'S',
      hunter: 'Donkey',
    },
    {
      selected: signal(false),
      reward: 150,
      target: '2 Trolls',
      rank: 'B',
      hunter: 'Meme Spammer',
    },
    {
      selected: signal(false),
      reward: 500,
      target: '1 Demon',
      rank: 'A',
      hunter: 'Dante',
    },
    {
      selected: signal(false),
      reward: 10,
      target: '5 Slimes',
      rank: 'C',
      hunter: '[Help Wanted]',
    },
  ]);

  sortAscending: boolean = true;
  tempInput: string = '';

  constructor() {
    afterRenderEffect(() => {
      this._headerCheckbox()!.nativeElement.indeterminate = this.partiallySelected();
    });
  }

  startEdit(
    event: KeyboardEvent | FocusEvent | undefined,
    task: Task,
    inputEl: HTMLInputElement,
  ): void {
    this.tempInput = task.hunter;
    inputEl.focus();

    if (!(event instanceof KeyboardEvent)) return;

    // Start editing with an alphanumeric character.
    if (event.key.length === 1) {
      this.tempInput = event.key;
    }
  }

  onClickEdit(widget: GridCellWidget, task: Task, inputEl: HTMLInputElement) {
    if (widget.isActivated()) return;

    widget.activate();
    setTimeout(() => this.startEdit(undefined, task, inputEl));
  }

  completeEdit(event: KeyboardEvent | FocusEvent | undefined, task: Task): void {
    if (!(event instanceof KeyboardEvent)) {
      return;
    }
    if (event.key === 'Enter') {
      task.hunter = this.tempInput;
    }
  }

  updateSelection(event: Event): void {
    const checked = (event.target as HTMLInputElement).checked;
    this.data().forEach((t) => t.selected.set(checked));
  }

  sortTaskById(): void {
    this.sortAscending = !this.sortAscending;
    if (this.sortAscending) {
      this.data.update((tasks) => tasks.sort((a, b) => a.reward - b.reward));
    } else {
      this.data.update((tasks) => tasks.sort((a, b) => b.reward - a.reward));
    }
  }
}
html
<table ngGrid class="retro-data-table">
  <thead>
    <tr ngGridRow>
      <th ngGridCell>
        <input
          ngGridCellWidget
          aria-label="Select all rows"
          type="checkbox"
          [checked]="allSelected()"
          (change)="updateSelection($event)"
          #headerCheckbox
        />
      </th>
      <th ngGridCell>
        <button
          ngGridCellWidget
          class="sort-button"
          aria-label="Sort by ID"
          (click)="sortTaskById()"
        >
          Reward
          <span
            aria-hidden="true"
            class="material-symbols-outlined"
            translate="no"
            aria-hidden="true"
          >
            {{ sortAscending ? 'arrow_upward' : 'arrow_downward' }}
          </span>
        </button>
      </th>
      <th ngGridCell>Target</th>
      <th ngGridCell>Rank</th>
      <th ngGridCell>Hunter</th>
    </tr>
  </thead>
  <tbody>
    @for (task of data(); track task) {
      <tr ngGridRow>
        <td ngGridCell>
          <input
            ngGridCellWidget
            aria-label="Select row {{ $index + 1 }}"
            type="checkbox"
            [(ngModel)]="task.selected"
          />
        </td>
        <td ngGridCell>${{ task.reward }}</td>
        <td ngGridCell>{{ task.target }}</td>
        <td ngGridCell>{{ task.rank }}</td>
        <td ngGridCell class="assignee-cell">
          <div
            type="button"
            ngGridCellWidget
            aria-label="edit hunter"
            widgetType="editable"
            (activated)="startEdit($event, task, assigneeInput)"
            (deactivated)="completeEdit($event, task)"
            #widget="ngGridCellWidget"
          >
            <span [class.hidden]="widget.isActivated()">{{ task.hunter }}</span>
            <input
              [class.hidden]="!widget.isActivated()"
              class="assignee-edit-input"
              [(ngModel)]="tempInput"
              #assigneeInput
            />
            <button
              tabindex="-1"
              aria-label="edit hunter"
              class="material-symbols-outlined assignee-edit-button"
              (click)="onClickEdit(widget, task, assigneeInput)"
              [class.hidden]="widget.isActivated()"
              translate="no"
            >
              edit
            </button>
          </div>
        </td>
      </tr>
    }
  </tbody>
</table>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-family: 'Press Start 2P';

  --retro-button-color: color-mix(in srgb, var(--symbolic-yellow) 90%, var(--page-background));
  --retro-button-text-color: color-mix(in srgb, var(--symbolic-yellow) 10%, white);
  --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);
}

.hidden {
  display: none;
}

button {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
}

input[type='checkbox'] {
  accent-color: var(--hot-pink);
  transform: scale(1.3);
  outline: none;
  cursor: pointer;
}

[ngGrid] {
  border-spacing: 0 0.5rem;
  display: table;
}

[ngGrid] th,
[ngGrid] td {
  padding: 0.5rem 0.75rem;
}

thead {
  background-color: var(--retro-button-color);
  color: var(--retro-button-text-color);
  box-shadow: var(--retro-elevated-shadow);
}

tbody [ngGridRow]:focus-within,
tbody [ngGridRow]:hover {
  background-color: var(--septenary-contrast);
}

[ngGridCell]:focus-within,
[ngGridCell]:hover {
  outline-offset: 4px;
  outline: 4px dashed color-mix(in srgb, var(--hot-pink) 60%, transparent);
}

.sort-button {
  display: flex;
  align-items: center;
  cursor: pointer;
  font-family: 'Press Start 2P';
  font-size: 1rem;
}

.assignee-cell [ngGridCellWidget] {
  display: flex;
  align-items: center;
  justify-content: space-between;
  outline: none;
}

.assignee-edit-button {
  visibility: hidden;
  cursor: pointer;
}

.assignee-cell:focus-within .assignee-edit-button,
.assignee-cell:hover .assignee-edit-button {
  visibility: initial;
}

.assignee-edit-input {
  outline: none;
  border: none;
  color: var(--full-contrast);
  background-color: var(--page-background);
  font-size: 1rem;
  padding: 0.5rem;
}

directive مربوط به ngGrid را روی table element، ngGridRow را روی هر row، و ngGridCell را روی هر cell اعمال کنید.

Calendar grid

Calendarها use case رایجی برای gridها هستند. این مثال یک month view را نشان می‌دهد که کاربران در آن dateها را با arrow keyها navigate می‌کنند.

ts
import {
  Component,
  computed,
  inject,
  signal,
  Signal,
  untracked,
  viewChildren,
  WritableSignal,
} from '@angular/core';
import {
  DateAdapter,
  MAT_DATE_FORMATS,
  MatDateFormats,
  provideNativeDateAdapter,
} from '@angular/material/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

const DAYS_PER_WEEK = 7;

interface CalendarCell<D = any> {
  displayName: string;
  ariaLabel: string;
  date: D;
  selected: WritableSignal<boolean>;
  day: number;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  providers: [provideNativeDateAdapter()],
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App<D> {
  private readonly _dayButtons = viewChildren(GridCellWidget);
  private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
  private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
  private readonly _firstWeekOffset = computed(() => {
    const firstDayOfMonth = this._dateAdapter.createDate(
      this._dateAdapter.getYear(this.viewMonth()),
      this._dateAdapter.getMonth(this.viewMonth()),
      1,
    );

    return (
      (DAYS_PER_WEEK +
        this._dateAdapter.getDayOfWeek(firstDayOfMonth) -
        this._dateAdapter.getFirstDayOfWeek()) %
      DAYS_PER_WEEK
    );
  });

  protected readonly monthYearLabel = computed(() =>
    this._dateAdapter
      .format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
      .toLocaleUpperCase(),
  );

  protected readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
    const prevMonthNumDays = this._dateAdapter.getNumDaysInMonth(
      this._dateAdapter.addCalendarMonths(this.viewMonth(), -1),
    );

    const days: number[] = [];
    for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
      days.push(prevMonthNumDays - i);
    }
    return days;
  });

  readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
    const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
    const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
    const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');

    const weekdays = longWeekdays.map((long, i) => {
      return {long, narrow: narrowWeekdays[i]};
    });
    return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
  });

  /** The current selected date. */
  readonly selectedDate: WritableSignal<D> = signal(this._dateAdapter.today());

  /** The current display month. */
  readonly viewMonth: WritableSignal<D> = signal(this.selectedDate());

  /** Calendar day cells. */
  readonly calendar = computed(() => {
    const month = this.viewMonth();

    const daysInMonth = this._dateAdapter.getNumDaysInMonth(month);
    const dateNames = this._dateAdapter.getDateNames();

    const calendar: CalendarCell[][] = [[]];
    for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
      if (cell == DAYS_PER_WEEK) {
        calendar.push([]);
        cell = 0;
      }
      const date = this._dateAdapter.createDate(
        this._dateAdapter.getYear(month),
        this._dateAdapter.getMonth(month),
        i + 1,
      );
      const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);

      calendar[calendar.length - 1].push({
        displayName: dateNames[i],
        ariaLabel,
        date,
        selected: signal(
          this._dateAdapter.compareDate(
            date,
            untracked(() => this.selectedDate()),
          ) === 0,
        ),
        day: i + 1,
      });
    }

    return calendar;
  });

  nextMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
  }

  prevMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
  }

  scrollDown(): void {
    this.nextMonth();
    setTimeout(() => this._dayButtons()[0]?.element.focus());
  }

  scrollUp(): void {
    this.prevMonth();
    setTimeout(() => this._dayButtons()[this._dayButtons().length - 1]?.element.focus());
  }

  onKeyDown(event: KeyboardEvent): void {
    const day = Number((event.target as Element).getAttribute('data-day'));
    if (!day) return;

    const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
    if (day > 7 && day <= viewMonthNumDays - 7) return;

    const arrowLeft = event.key === 'ArrowLeft';
    const arrowRight = event.key === 'ArrowRight';
    const arrowUp = event.key === 'ArrowUp';
    const arrowDown = event.key === 'ArrowDown';

    if ((day === 1 && arrowLeft) || (day <= 7 && arrowUp)) {
      this.scrollUp();
    }

    if ((day === viewMonthNumDays && arrowRight) || (day > viewMonthNumDays - 7 && arrowDown)) {
      this.scrollDown();
    }
  }
}
html
<div class="calendar basic-calendar">
  <div class="calendar-header">
    <button class="month-control" aria-label="previous month" (click)="prevMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_left</span
      >
    </button>
    <h3>{{ monthYearLabel() }}</h3>
    <button class="month-control" aria-label="next month" (click)="nextMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_right</span
      >
    </button>
  </div>

  <table
    ngGrid
    colWrap="continuous"
    rowWrap="nowrap"
    [enableSelection]="true"
    [softDisabled]="false"
    selectionMode="explicit"
    (keydown)="onKeyDown($event)"
  >
    <thead>
      <tr>
        @for (day of weekdays(); track day.long) {
          <th scope="col">
            <span class="visually-hidden">{{ day.long }}</span>
            <span aria-hidden="true">{{ day.narrow }}</span>
          </th>
        }
      </tr>
    </thead>

    @for (week of calendar(); track week) {
      <tr ngGridRow>
        @if ($first) {
          @for (day of daysFromPrevMonth(); track day) {
            <td ngGridCell disabled>{{ day }}</td>
          }
        }

        @for (day of week; track day) {
          <td ngGridCell [(selected)]="day.selected">
            <button ngGridCellWidget [attr.aria-label]="day.ariaLabel" [attr.data-day]="day.day">
              {{ day.displayName }}
            </button>
          </td>
        }

        @if ($last && week.length < 7) {
          @for (day of [].constructor(7 - week.length); track $index) {
            <td ngGridCell disabled>{{ $index + 1 }}</td>
          }
        }
      </tr>
    }
  </table>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.calendar {
  display: flex;
  flex-direction: column;
  background-color: var(--septenary-contrast);
  padding: 0.5rem;
}

.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-block-end: 0.5rem;
}

.calendar-header h3 {
  margin: 0;
  font-size: 1.2rem;
}

button {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
}

button:hover,
button:focus {
  background-color: var(--senary-contrast);
}

button:focus {
  outline-offset: -1px;
  outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}

.visually-hidden {
  clip: rect(1px, 1px, 1px, 1px);
  height: 1px;
  width: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
}

.month-control {
  width: 45px;
  height: 45px;
  cursor: pointer;
}

[ngGrid] {
  display: table;
  border-spacing: 0;
}

[ngGridCell] {
  width: 50px;
  height: 50px;
  text-align: center;
  vertical-align: middle;
}

[ngGridCell][aria-selected='true'] > button[ngGridCellWidget] {
  background-color: var(--electric-violet);
  color: var(--octonary-contrast);
}

[ngGridCell][aria-disabled='true'] {
  color: var(--senary-contrast);
}

thead {
  background-image: var(--pink-to-purple-horizontal-gradient);
  background-clip: text;
  color: transparent;
}

button[ngGridCellWidget] {
  width: 45px;
  height: 45px;
  cursor: pointer;
}
ts
import {
  Component,
  computed,
  inject,
  signal,
  Signal,
  untracked,
  viewChildren,
  WritableSignal,
} from '@angular/core';
import {
  DateAdapter,
  MAT_DATE_FORMATS,
  MatDateFormats,
  provideNativeDateAdapter,
} from '@angular/material/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

const DAYS_PER_WEEK = 7;

interface CalendarCell<D = any> {
  displayName: string;
  ariaLabel: string;
  date: D;
  selected: WritableSignal<boolean>;
  day: number;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  providers: [provideNativeDateAdapter()],
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App<D> {
  private readonly _dayButtons = viewChildren(GridCellWidget);
  private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
  private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
  private readonly _firstWeekOffset = computed(() => {
    const firstDayOfMonth = this._dateAdapter.createDate(
      this._dateAdapter.getYear(this.viewMonth()),
      this._dateAdapter.getMonth(this.viewMonth()),
      1,
    );

    return (
      (DAYS_PER_WEEK +
        this._dateAdapter.getDayOfWeek(firstDayOfMonth) -
        this._dateAdapter.getFirstDayOfWeek()) %
      DAYS_PER_WEEK
    );
  });

  protected readonly monthYearLabel = computed(() =>
    this._dateAdapter
      .format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
      .toLocaleUpperCase(),
  );

  protected readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
    const prevMonthNumDays = this._dateAdapter.getNumDaysInMonth(
      this._dateAdapter.addCalendarMonths(this.viewMonth(), -1),
    );

    const days: number[] = [];
    for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
      days.push(prevMonthNumDays - i);
    }
    return days;
  });

  readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
    const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
    const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
    const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');

    const weekdays = longWeekdays.map((long, i) => {
      return {long, narrow: narrowWeekdays[i]};
    });
    return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
  });

  /** The current selected date. */
  readonly selectedDate: WritableSignal<D> = signal(this._dateAdapter.today());

  /** The current display month. */
  readonly viewMonth: WritableSignal<D> = signal(this.selectedDate());

  /** Calendar day cells. */
  readonly calendar = computed(() => {
    const month = this.viewMonth();

    const daysInMonth = this._dateAdapter.getNumDaysInMonth(month);
    const dateNames = this._dateAdapter.getDateNames();

    const calendar: CalendarCell[][] = [[]];
    for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
      if (cell == DAYS_PER_WEEK) {
        calendar.push([]);
        cell = 0;
      }
      const date = this._dateAdapter.createDate(
        this._dateAdapter.getYear(month),
        this._dateAdapter.getMonth(month),
        i + 1,
      );
      const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);

      calendar[calendar.length - 1].push({
        displayName: dateNames[i],
        ariaLabel,
        date,
        selected: signal(
          this._dateAdapter.compareDate(
            date,
            untracked(() => this.selectedDate()),
          ) === 0,
        ),
        day: i + 1,
      });
    }

    return calendar;
  });

  nextMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
  }

  prevMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
  }

  scrollDown(): void {
    this.nextMonth();
    setTimeout(() => this._dayButtons()[0]?.element.focus());
  }

  scrollUp(): void {
    this.prevMonth();
    setTimeout(() => this._dayButtons()[this._dayButtons().length - 1]?.element.focus());
  }

  onKeyDown(event: KeyboardEvent): void {
    const day = Number((event.target as Element).getAttribute('data-day'));
    if (!day) return;

    const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
    if (day > 7 && day <= viewMonthNumDays - 7) return;

    const arrowLeft = event.key === 'ArrowLeft';
    const arrowRight = event.key === 'ArrowRight';
    const arrowUp = event.key === 'ArrowUp';
    const arrowDown = event.key === 'ArrowDown';

    if ((day === 1 && arrowLeft) || (day <= 7 && arrowUp)) {
      this.scrollUp();
    }

    if ((day === viewMonthNumDays && arrowRight) || (day > viewMonthNumDays - 7 && arrowDown)) {
      this.scrollDown();
    }
  }
}
html
<div class="calendar material-calendar">
  <div class="calendar-header">
    <button class="month-control" aria-label="previous month" (click)="prevMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_left</span
      >
    </button>
    <h3>{{ monthYearLabel() }}</h3>
    <button class="month-control" aria-label="next month" (click)="nextMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_right</span
      >
    </button>
  </div>

  <table
    ngGrid
    colWrap="continuous"
    rowWrap="nowrap"
    [enableSelection]="true"
    [softDisabled]="false"
    selectionMode="explicit"
    (keydown)="onKeyDown($event)"
  >
    <thead>
      <tr>
        @for (day of weekdays(); track day.long) {
          <th scope="col">
            <span class="visually-hidden">{{ day.long }}</span>
            <span aria-hidden="true">{{ day.narrow }}</span>
          </th>
        }
      </tr>
    </thead>

    @for (week of calendar(); track week) {
      <tr ngGridRow>
        @if ($first) {
          @for (day of daysFromPrevMonth(); track day) {
            <td ngGridCell disabled>{{ day }}</td>
          }
        }

        @for (day of week; track day) {
          <td ngGridCell [(selected)]="day.selected">
            <button ngGridCellWidget [attr.aria-label]="day.ariaLabel" [attr.data-day]="day.day">
              {{ day.displayName }}
            </button>
          </td>
        }

        @if ($last && week.length < 7) {
          @for (day of [].constructor(7 - week.length); track $index) {
            <td ngGridCell disabled>{{ $index + 1 }}</td>
          }
        }
      </tr>
    }
  </table>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

.calendar {
  display: flex;
  flex-direction: column;
  background-color: var(--septenary-contrast);
  border-radius: 0.5rem;
  padding: 0.5rem;
}

.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-block-end: 0.5rem;
}

.calendar-header h3 {
  margin: 0;
  font-size: 1.2rem;
}

button {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
  border-radius: 50%;
}

button:hover,
button:focus {
  background-color: var(--senary-contrast);
}

button:focus {
  outline-offset: -1px;
  outline: 1px solid color-mix(in srgb, var(--bright-blue) 60%, transparent);
}

.visually-hidden {
  clip: rect(1px, 1px, 1px, 1px);
  height: 1px;
  width: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
}

.month-control {
  width: 45px;
  height: 45px;
  cursor: pointer;
}

[ngGrid] {
  display: table;
  border-spacing: 0;
}

[ngGridCell] {
  width: 50px;
  height: 50px;
  text-align: center;
  vertical-align: middle;
}

[ngGridCell][aria-selected='true'] > button[ngGridCellWidget] {
  background-color: var(--indigo-blue);
  color: var(--octonary-contrast);
}

[ngGridCell][aria-disabled='true'] {
  color: var(--senary-contrast);
}

thead {
  color: var(--secondary-contrast);
}

button[ngGridCellWidget] {
  width: 45px;
  height: 45px;
  cursor: pointer;
}
ts
import {
  Component,
  computed,
  inject,
  signal,
  Signal,
  untracked,
  viewChildren,
  WritableSignal,
} from '@angular/core';
import {
  DateAdapter,
  MAT_DATE_FORMATS,
  MatDateFormats,
  provideNativeDateAdapter,
} from '@angular/material/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

const DAYS_PER_WEEK = 7;

interface CalendarCell<D = any> {
  displayName: string;
  ariaLabel: string;
  date: D;
  selected: WritableSignal<boolean>;
  day: number;
}

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  providers: [provideNativeDateAdapter()],
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App<D> {
  private readonly _dayButtons = viewChildren(GridCellWidget);
  private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
  private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
  private readonly _firstWeekOffset = computed(() => {
    const firstDayOfMonth = this._dateAdapter.createDate(
      this._dateAdapter.getYear(this.viewMonth()),
      this._dateAdapter.getMonth(this.viewMonth()),
      1,
    );

    return (
      (DAYS_PER_WEEK +
        this._dateAdapter.getDayOfWeek(firstDayOfMonth) -
        this._dateAdapter.getFirstDayOfWeek()) %
      DAYS_PER_WEEK
    );
  });

  protected readonly monthYearLabel = computed(() =>
    this._dateAdapter
      .format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
      .toLocaleUpperCase(),
  );

  protected readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
    const prevMonthNumDays = this._dateAdapter.getNumDaysInMonth(
      this._dateAdapter.addCalendarMonths(this.viewMonth(), -1),
    );

    const days: number[] = [];
    for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
      days.push(prevMonthNumDays - i);
    }
    return days;
  });

  readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
    const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
    const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
    const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');

    const weekdays = longWeekdays.map((long, i) => {
      return {long, narrow: narrowWeekdays[i]};
    });
    return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
  });

  /** The current selected date. */
  readonly selectedDate: WritableSignal<D> = signal(this._dateAdapter.today());

  /** The current display month. */
  readonly viewMonth: WritableSignal<D> = signal(this.selectedDate());

  /** Calendar day cells. */
  readonly calendar = computed(() => {
    const month = this.viewMonth();

    const daysInMonth = this._dateAdapter.getNumDaysInMonth(month);
    const dateNames = this._dateAdapter.getDateNames();

    const calendar: CalendarCell[][] = [[]];
    for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
      if (cell == DAYS_PER_WEEK) {
        calendar.push([]);
        cell = 0;
      }
      const date = this._dateAdapter.createDate(
        this._dateAdapter.getYear(month),
        this._dateAdapter.getMonth(month),
        i + 1,
      );
      const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);

      calendar[calendar.length - 1].push({
        displayName: dateNames[i],
        ariaLabel,
        date,
        selected: signal(
          this._dateAdapter.compareDate(
            date,
            untracked(() => this.selectedDate()),
          ) === 0,
        ),
        day: i + 1,
      });
    }

    return calendar;
  });

  nextMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
  }

  prevMonth(): void {
    this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
  }

  scrollDown(): void {
    this.nextMonth();
    setTimeout(() => this._dayButtons()[0]?.element.focus());
  }

  scrollUp(): void {
    this.prevMonth();
    setTimeout(() => this._dayButtons()[this._dayButtons().length - 1]?.element.focus());
  }

  onKeyDown(event: KeyboardEvent): void {
    const day = Number((event.target as Element).getAttribute('data-day'));
    if (!day) return;

    const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
    if (day > 7 && day <= viewMonthNumDays - 7) return;

    const arrowLeft = event.key === 'ArrowLeft';
    const arrowRight = event.key === 'ArrowRight';
    const arrowUp = event.key === 'ArrowUp';
    const arrowDown = event.key === 'ArrowDown';

    if ((day === 1 && arrowLeft) || (day <= 7 && arrowUp)) {
      this.scrollUp();
    }

    if ((day === viewMonthNumDays && arrowRight) || (day > viewMonthNumDays - 7 && arrowDown)) {
      this.scrollDown();
    }
  }
}
html
<div class="calendar retro-calendar">
  <div class="calendar-header">
    <button class="month-control" aria-label="previous month" (click)="prevMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_left</span
      >
    </button>
    <h3>{{ monthYearLabel() }}</h3>
    <button class="month-control" aria-label="next month" (click)="nextMonth()">
      <span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
        >chevron_right</span
      >
    </button>
  </div>

  <table
    ngGrid
    colWrap="continuous"
    rowWrap="nowrap"
    [enableSelection]="true"
    [softDisabled]="false"
    selectionMode="explicit"
    (keydown)="onKeyDown($event)"
  >
    <thead>
      <tr>
        @for (day of weekdays(); track day.long) {
          <th scope="col">
            <span class="visually-hidden">{{ day.long }}</span>
            <span aria-hidden="true">{{ day.narrow }}</span>
          </th>
        }
      </tr>
    </thead>

    @for (week of calendar(); track week) {
      <tr ngGridRow>
        @if ($first) {
          @for (day of daysFromPrevMonth(); track day) {
            <td ngGridCell disabled>{{ day }}</td>
          }
        }

        @for (day of week; track day) {
          <td ngGridCell [(selected)]="day.selected">
            <button ngGridCellWidget [attr.aria-label]="day.ariaLabel" [attr.data-day]="day.day">
              {{ day.displayName }}
            </button>
          </td>
        }

        @if ($last && week.length < 7) {
          @for (day of [].constructor(7 - week.length); track $index) {
            <td ngGridCell disabled>{{ $index + 1 }}</td>
          }
        }
      </tr>
    }
  </table>
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-family: 'Press Start 2P';

  --retro-button-color: color-mix(in srgb, var(--always-pink) 90%, var(--page-background));
  --retro-button-text-color: color-mix(in srgb, var(--always-pink) 10%, white);
  --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);
}

.calendar {
  display: flex;
  flex-direction: column;
  background-color: var(--septenary-contrast);
  padding: 0.5rem;
  box-shadow: var(--retro-flat-shadow);
}

.calendar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-block-end: 0.5rem;
}

.calendar-header h3 {
  margin: 0;
  font-size: 1.2rem;
}

button {
  font-family: 'Press Start 2P';
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  outline: none;
}

button:hover,
button:focus {
  background-color: var(--senary-contrast);
}

button:focus {
  outline-offset: 4px;
  outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}

.visually-hidden {
  clip: rect(1px, 1px, 1px, 1px);
  height: 1px;
  width: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap;
}

.month-control {
  width: 45px;
  height: 45px;
  cursor: pointer;
}

[ngGrid] {
  display: table;
  border-spacing: 0;
}

[ngGridCell] {
  width: 50px;
  height: 50px;
  text-align: center;
  vertical-align: middle;
}

[ngGridCell][aria-selected='true'] > button[ngGridCellWidget] {
  background-color: var(--retro-button-color);
  color: var(--retro-button-text-color);
  box-shadow: var(--retro-clickable-shadow);
}

[ngGridCell][aria-disabled='true'] {
  color: var(--senary-contrast);
}

thead {
  background-image: var(--orange-to-pink-vertical-gradient);
  background-clip: text;
  color: transparent;
}

button[ngGridCellWidget] {
  width: 45px;
  height: 45px;
  cursor: pointer;
}

کاربران می‌توانند وقتی focus روی یک cell است، با فشردن Enter یا Space یک date را activate کنند.

Layout grid

برای گروه‌بندی elementهای تعاملی و کاهش tab stopها از layout grid استفاده کنید. این مثال gridای از pill buttonها را نشان می‌دهد.

ts
import {Component, signal} from '@angular/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
  tags = signal(['Unleash', 'Your', 'Creativity', 'With', 'Angular', 'Aria']);

  removeTag(index: number) {
    this.tags.update((tags) => [...tags.slice(0, index), ...tags.slice(index + 1)]);
  }
}
html
<div ngGrid colWrap="continuous" class="basic-pill-list">
  @for (tag of tags(); track $index) {
    <div ngGridRow>
      <span ngGridCell>#{{ tag }}</span>
      <span ngGridCell>
        <button
          ngGridCellWidget
          aria-label="remove tag"
          class="material-symbols-outlined"
          (click)="removeTag($index)"
          translate="no"
        >
          close
        </button>
      </span>
    </div>
  }
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

[ngGrid] {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.5rem;
  max-width: 400px;
  background-image: var(--pink-to-purple-horizontal-gradient);
  background-clip: text;
  color: transparent;
}

[ngGridRow] {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  border: 1px dotted var(--senary-contrast);
  padding: 0 0.25rem 0 0.75rem;
}

[ngGridRow]:focus-within,
[ngGridRow]:hover {
  outline-offset: -1px;
  outline: 1px solid color-mix(in srgb, var(--vivid-pink) 80%, transparent);
}

[ngGridRow]:has(button[ngGridCellWidget]:focus),
[ngGridRow]:has(button[ngGridCellWidget]:hover) {
  outline: none;
}

[ngGridCell] {
  display: flex;
  outline: none;
}

button[ngGridCellWidget] {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  font-size: 1.2rem;
  width: 1.5rem;
  height: 1.5rem;
  margin: 0.25rem;
  border-radius: 50%;
  cursor: pointer;
}

button[ngGridCellWidget]:focus,
button[ngGridCellWidget]:hover {
  outline: 1px solid color-mix(in srgb, var(--vivid-pink) 80%, transparent);
}
ts
import {Component, signal} from '@angular/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
  tags = signal(['Unleash', 'Your', 'Creativity', 'With', 'Angular', 'Aria']);

  removeTag(index: number) {
    this.tags.update((tags) => [...tags.slice(0, index), ...tags.slice(index + 1)]);
  }
}
html
<div ngGrid colWrap="continuous" class="material-pill-list">
  @for (tag of tags(); track $index) {
    <div ngGridRow>
      <span ngGridCell>{{ tag }}</span>
      <span ngGridCell>
        <button
          ngGridCellWidget
          aria-label="remove tag"
          class="material-symbols-outlined"
          (click)="removeTag($index)"
          translate="no"
        >
          close
        </button>
      </span>
    </div>
  }
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');

:host {
  display: flex;
  justify-content: center;
  font-family: var(--inter-font);
}

[ngGrid] {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.5rem;
  max-width: 400px;
}

[ngGridRow] {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  border: 1px solid var(--senary-contrast);
  border-radius: 0.5rem;
  padding: 0 0.25rem 0 0.75rem;
}

[ngGridRow]:focus-within,
[ngGridRow]:hover {
  background-color: var(--senary-contrast);
}

[ngGridRow]:has(button[ngGridCellWidget]:focus),
[ngGridRow]:has(button[ngGridCellWidget]:hover) {
  background-color: initial;
}

[ngGridCell] {
  display: flex;
  outline: none;
}

button[ngGridCellWidget] {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  font-size: 1.2rem;
  width: 1.5rem;
  height: 1.5rem;
  margin: 0.25rem;
  border-radius: 50%;
  cursor: pointer;
}

button[ngGridCellWidget]:focus,
button[ngGridCellWidget]:hover {
  outline: none;
  background-color: var(--septenary-contrast);
}
ts
import {Component, signal} from '@angular/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';

@Component({
  selector: 'app-root',
  templateUrl: 'app.html',
  styleUrl: 'app.css',
  imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
  tags = signal(['Unleash', 'Your', 'Creativity', 'With', 'Angular', 'Aria']);

  removeTag(index: number) {
    this.tags.update((tags) => [...tags.slice(0, index), ...tags.slice(index + 1)]);
  }
}
html
<div ngGrid colWrap="continuous" class="retro-pill-list">
  @for (tag of tags(); track $index) {
    <div ngGridRow>
      <span ngGridCell>#{{ tag }}</span>
      <span ngGridCell>
        <button
          ngGridCellWidget
          aria-label="remove tag"
          class="material-symbols-outlined"
          (click)="removeTag($index)"
          translate="no"
        >
          close
        </button>
      </span>
    </div>
  }
</div>
css
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');

:host {
  display: flex;
  justify-content: center;
  font-family: 'Press Start 2P';

  --retro-button-color: color-mix(in srgb, var(--always-pink) 90%, var(--page-background));
  --retro-button-text-color: color-mix(in srgb, var(--always-pink) 10%, white);
  --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);
}

[ngGrid] {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 1rem;
  max-width: 400px;
}

[ngGridRow] {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0 0.25rem 0 0.75rem;
  color: var(--retro-button-text-color);
  background-color: var(--retro-button-color);
  box-shadow: var(--retro-clickable-shadow);
}

[ngGridRow]:focus-within,
[ngGridRow]:hover {
  outline-offset: 4px;
  outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}

[ngGridRow]:has(button[ngGridCellWidget]:focus),
[ngGridRow]:has(button[ngGridCellWidget]:hover) {
  outline: none;
}

[ngGridCell] {
  display: flex;
  outline: none;
}

button[ngGridCellWidget] {
  border: unset;
  padding: unset;
  color: unset;
  background: unset;
  font-size: 1.5rem;
  margin: 0.25rem;
  cursor: pointer;
}

button[ngGridCellWidget]:focus,
button[ngGridCellWidget]:hover {
  outline-offset: 8px;
  outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}

به جای tab کردن بین تک‌تک buttonها، کاربران با arrow keyها navigate می‌کنند و فقط یک button tab focus دریافت می‌کند.

Selection و focus modeها

selection را با [enableSelection]="true" فعال کنید و configure کنید focus و selection چطور با هم تعامل کنند.

html
<table
  ngGrid
  [enableSelection]="true"
  [selectionMode]="'explicit'"
  [multi]="true"
  [focusMode]="'roving'"
>
  <tr ngGridRow>
    <td ngGridCell>Cell 1</td>
    <td ngGridCell>Cell 2</td>
  </tr>
</table>

Selection modeها:

  • follow: cell دارای focus به صورت خودکار selected می‌شود.
  • explicit: کاربران با Space یا click cellها را select می‌کنند.

Focus modeها:

  • roving: focus با استفاده از tabindex به cellها منتقل می‌شود \(برای gridهای ساده بهتر است\).
  • activedescendant: focus روی grid container می‌ماند و aria-activedescendant cell فعال را نشان می‌دهد \(برای virtual scrolling بهتر است\).

Testing

Angular Aria برای testing کامپوننت‌های grid، component harness ارائه می‌کند. این نمونه نحوه استفاده از harnessها را در یک component test نشان می‌دهد:

ts
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {GridHarness} from '@angular/aria/grid/testing';
import {MyGridComponent} from './my-grid'; // Your component

describe('MyGridComponent', () => {
  let fixture: ComponentFixture<MyGridComponent>;
  let loader: HarnessLoader;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      imports: [MyGridComponent],
    });

    fixture = TestBed.createComponent(MyGridComponent);
    await fixture.whenStable();
    loader = TestbedHarnessEnvironment.loader(fixture);
  });

  it('should read cell values and focus cells', async () => {
    const grid = await loader.getHarness(GridHarness);

    // Get all cells text in a 2D array organized by rows
    const cellTexts = await grid.getCellTextByIndex();
    expect(cellTexts).toEqual([
      ['Cell 1.1', 'Cell 1.2'],
      ['Cell 2.1', 'Cell 2.2'],
    ]);

    // Get a specific cell by text
    const cells = await grid.getCells({text: 'Cell 1.1'});
    expect(cells.length).toBe(1);
    const cell = cells[0];

    // Verify cell state
    expect(await cell.isSelected()).toBe(true);
    expect(await cell.isActive()).toBe(true);

    // Focus the cell
    await cell.focus();
    expect(await cell.isFocused()).toBe(true);
  });
});

API reference

برای مستندات دقیق API، referenceهای API زیر را بررسی کنید: