グリッド
概要
グリッドを使用すると、ユーザーは方向矢印キー、Home、End、Page Up/Downを使用して2次元データやインタラクティブな要素をナビゲートできます。グリッドは、データテーブル、カレンダー、スプレッドシート、および関連するインタラクティブな要素をグループ化するレイアウトパターンで機能します。
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.component.html', styleUrl: 'app.component.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;}
使用法
グリッドは、ユーザーが複数の方向へのキーボードナビゲーションを必要とする、行と列で構成されたデータやインタラクティブな要素に適しています。
次の場合にグリッドを使用します:
- 編集可能または選択可能なセルを持つインタラクティブなデータテーブルを構築する場合
- カレンダーや日付ピッカーを作成する場合
- スプレッドシートのようなインターフェースを実装する場合
- ページのタブストップを減らすために、インタラクティブな要素(ボタン、チェックボックス)をグループ化する場合
- 2次元のキーボードナビゲーションを必要とするインターフェースを構築する場合
次の場合にグリッドの使用を避けます:
- 単純な読み取り専用のテーブルを表示する場合(代わりにセマンティックなHTMLの
<table>を使用します) - 単一列のリストを表示する場合(代わりにListboxを使用します)
- 階層データを表示する場合(代わりにTreeを使用します)
- 表形式のレイアウトではないフォームを構築する場合(標準のフォームコントロールを使用します)
機能
- 2次元ナビゲーション - 矢印キーですべての方向にセル間を移動
- フォーカスモード - roving tabindexまたはactivedescendantのフォーカス戦略から選択
- 選択のサポート - 単一または複数選択モードによるオプションのセル選択
- 折り返し動作 - グリッドの端でナビゲーションがどのように折り返すかを設定 (continuous、loop、またはnowrap)
- 範囲選択 - 修飾キーまたはドラッグで複数のセルを選択
- 無効状態 - グリッド全体または個々のセルを無効化
- RTLサポート - 右から左へ記述する言語の自動ナビゲーション
例
データテーブルグリッド
ユーザーが矢印キーを使ってセル間を移動する必要があるインタラクティブなテーブルには、グリッドを使用します。この例は、キーボードナビゲーションを備えた基本的なデータテーブルを示しています。
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.component.html', styleUrl: 'app.component.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"> {{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" (onActivate)="startEdit($event, task, assigneeInput)" (onDeactivate)="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.component.html', styleUrl: 'app.component.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"> {{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" (onActivate)="startEdit($event, task, assigneeInput)" (onDeactivate)="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(--gray-1000)); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --retro-flat-shadow: 4px 0px 0px 0px var(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 8px 8px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 0px 0px 0px 0px var(--gray-700);}.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を各セルに適用します。
カレンダーグリッド
カレンダーはグリッドの一般的なユースケースです。この例は、ユーザーが矢印キーを使って日付を移動する月表示を示しています。
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.component.html', styleUrl: 'app.component.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">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">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.component.html', styleUrl: 'app.component.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">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">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.component.html', styleUrl: 'app.component.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">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">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(--gray-1000)); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --retro-flat-shadow: 4px 0px 0px 0px var(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 8px 8px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 0px 0px 0px 0px var(--gray-700);}.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キーを押すことで、日付をアクティブにできます。
レイアウトグリッド
レイアウトグリッドを使用して、インタラクティブな要素をグループ化し、タブストップを減らします。この例は、ピルボタンのグリッドを示しています。
TS
import {Component, signal} from '@angular/core';import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';@Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrl: 'app.component.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.component.html', styleUrl: 'app.component.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.component.html', styleUrl: 'app.component.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(--gray-1000)); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --retro-flat-shadow: 4px 0px 0px 0px var(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 8px 8px 0px 0px var(--gray-700); --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(--gray-700), 0px 4px 0px 0px var(--gray-700), -4px 0px 0px 0px var(--gray-700), 0px -4px 0px 0px var(--gray-700), 0px 0px 0px 0px var(--gray-700);}[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);}
各ボタンをタブで移動する代わりに、ユーザーは矢印キーで移動し、1つのボタンのみがタブフォーカスを受け取ります。
選択とフォーカスモード
[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がアクティブなセルを示します(仮想スクロールに適しています)
API
Grid
行とセルのキーボードナビゲーションとフォーカス管理を提供するコンテナディレクティブです。
Inputs
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
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
グリッド内の行を表し、グリッドセルのコンテナとして機能します。
Inputs
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
rowIndex |
number |
auto | グリッド内でのこの行のインデックス |
GridCell
グリッド行内の個々のセルを表します。
Inputs
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
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> |
セルが現在フォーカスを持っているかどうか |