Spec-Zone.ru › Angular

Сетка

Шаблон сетки ARIA Справочник по API сетки

Обзор

Сетка позволяет пользователям перемещаться по двухмерным данным или интерактивным элементам с помощью клавиш со стрелками, Home, End и Page Up/Down. Сетки подходят для таблиц данных, календарей, электронных таблиц и макетов, группирующих связанные интерактивные элементы.

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);
}
<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>
@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;
}

Использование

Сетки хорошо подходят для данных или интерактивных элементов, организованных в строки и столбцы, когда пользователям нужна клавиатурная навигация в нескольких направлениях.

Используйте сетки, если:

  • Создаёте интерактивные таблицы данных с редактируемыми или выбираемыми ячейками
  • Создаёте календари или средства выбора даты
  • Реализуете интерфейсы, похожие на электронные таблицы
  • Группируете интерактивные элементы (кнопки, флажки), чтобы уменьшить количество остановок при переходе по странице клавишей Tab
  • Создаёте интерфейсы, требующие двухмерной навигации с клавиатуры

Не используйте сетки, если:

  • Отображаете простые таблицы только для чтения (вместо этого используйте семантическую HTML-разметку <table>)
  • Показываете списки в один столбец (вместо этого используйте список выбора)
  • Отображаете иерархические данные (вместо этого используйте дерево)
  • Создаёте формы без табличной структуры (используйте стандартные элементы управления формы)

Возможности

  • Двухмерная навигация — клавиши со стрелками перемещают между ячейками во всех направлениях
  • Режимы фокуса — выберите стратегию управления фокусом с помощью roving tabindex или activedescendant
  • Поддержка выбора — необязательный выбор ячеек в режимах одиночного или множественного выбора
  • Зацикливание навигации — настройте поведение навигации у краёв сетки (непрерывное, циклическое или без зацикливания)
  • Выбор диапазона — выбирайте несколько ячеек с помощью клавиш-модификаторов или перетаскивания
  • Отключение — отключайте всю сетку или отдельные ячейки
  • Поддержка RTL — автоматическая навигация для языков с письмом справа налево

Примеры

Сетка таблицы данных

Используйте сетку для интерактивных таблиц, в которых пользователям нужно перемещаться между ячейками с помощью клавиш со стрелками. В этом примере показана базовая таблица данных с клавиатурной навигацией.

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));
    }
  }
}
<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>
@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;
}
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));
    }
  }
}
<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>
@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;
}

Примените директиву ngGrid к элементу таблицы, ngGridRow — к каждой строке, а ngGridCell — к каждой ячейке.

Сетка календаря

Календари — распространённый вариант использования сеток. В этом примере показан вид месяца, в котором пользователи перемещаются по датам с помощью клавиш со стрелками.

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();
    }
  }
}
<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>
@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;
}
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();
    }
  }
}
<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>
@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;
}
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();
    }
  }
}
<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>
@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;
}

Пользователи могут выбрать дату, нажав Enter или Space, когда фокус находится на ячейке.

Сетка макета

Используйте сетку макета для группировки интерактивных элементов и уменьшения количества остановок при переходе клавишей Tab. В этом примере показана сетка кнопок-пилюль.

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)]);
  }
}
<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>
@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);
}
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)]);
  }
}
<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>
@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);
}
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)]);
  }
}
<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>
@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 пользователи перемещаются с помощью клавиш со стрелками, и только одна кнопка получает фокус при переходе клавишей Tab.

Режимы выбора и фокуса

Включите выбор с помощью [enableSelection]="true" и настройте взаимодействие фокуса и выбора.

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

Режимы выбора:

  • follow: ячейка с фокусом выбирается автоматически
  • explicit: пользователи выбирают ячейки клавишей Space или щелчком мыши

Режимы фокуса:

  • roving: фокус перемещается по ячейкам с помощью tabindex (лучше подходит для простых сеток)
  • activedescendant: фокус остаётся на контейнере сетки, aria-activedescendant указывает активную ячейку (лучше подходит для виртуальной прокрутки)

Тестирование

Angular Aria предоставляет компонентные тестовые оболочки для тестирования компонентов сетки. Вот пример использования тестовых оболочек в тесте компонента:

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

Grid

Директива контейнера, которая обеспечивает клавиатурную навигацию и управление фокусом для строк и ячеек.

Входные параметры

Свойство Тип По умолчанию Описание
enableSelection boolean false Включён ли выбор для сетки
disabled boolean false Отключает всю сетку
softDisabled boolean true Если true, на отключённые ячейки можно перевести фокус, но они не будут интерактивными
focusMode 'roving' | 'activedescendant' 'roving' Стратегия управления фокусом, используемая сеткой
rowWrap 'continuous' | 'loop' | 'nowrap' 'loop' Поведение зацикливания навигации по строкам
colWrap 'continuous' | 'loop' | 'nowrap' 'loop' Поведение зацикливания навигации по столбцам
multi boolean false Можно ли выбирать несколько ячеек
selectionMode 'follow' | 'explicit' 'follow' Следует ли выбор за фокусом или требует отдельного действия
enableRangeSelection boolean false Включает выбор диапазона с помощью клавиш-модификаторов или перетаскивания

GridRow

Представляет строку сетки и служит контейнером для ячеек сетки.

Входные параметры

Свойство Тип По умолчанию Описание
rowIndex number auto Индекс этой строки в сетке

GridCell

Представляет отдельную ячейку в строке сетки.

Входные параметры

Свойство Тип По умолчанию Описание
id string auto Уникальный идентификатор ячейки
role string 'gridcell' Роль ячейки: gridcell, columnheader или rowheader
disabled boolean false Отключает эту ячейку
selected boolean false Выбрана ли ячейка (поддерживается двусторонняя привязка)
selectable boolean true Можно ли выбрать ячейку
rowSpan number — Количество строк, занимаемых ячейкой
colSpan number — Количество столбцов, занимаемых ячейкой
rowIndex number — Индекс строки ячейки
colIndex number — Индекс столбца ячейки
orientation 'vertical' | 'horizontal' 'horizontal' Ориентация элементов управления внутри ячейки
wrap boolean true Зацикливается ли навигация по элементам управления внутри ячейки

Сигналы

Свойство Тип Описание
active Signal<boolean> Находится ли ячейка в данный момент в фокусе

GridCellWidget

Применяется к интерактивному элементу внутри ячейки сетки, чтобы приостанавливать навигацию по сетке.

Входные параметры

Свойство Тип По умолчанию Описание
id string auto Уникальный идентификатор элемента управления
widgetType 'simple' | 'complex' | 'editable' 'simple' Тип элемента управления, определяющий поведение при активации
disabled boolean false Отключает этот элемент управления в ячейке
focusTarget ElementResolver<HTMLElement> — Необязательная ссылка на элемент, который получит фокус при активации
tabindex number — Переопределение tabindex для элемента управления

Выходные события

Свойство Тип Описание
activated EventEmitter<KeyboardEvent | FocusEvent | undefined> Срабатывает при активации элемента управления в ячейке
deactivated EventEmitter<KeyboardEvent | FocusEvent | undefined> Срабатывает при деактивации элемента управления в ячейке

Методы

Метод Параметры Описание
activate нет Принудительно активирует элемент управления
deactivate нет Принудительно деактивирует элемент управления

Super-powered by Google ©2010–2025.
Code licensed under an MIT-style License. Documentation licensed under CC BY 4.0.
https://angular.dev/guide/aria/grid

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API