グリッド
概要
グリッドを使用すると、ユーザーは方向矢印キー、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.html',
styleUrl: 'app.css',
imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
readonly gridData: Cell[][] = generateValidGrid(6, 6);
}
HTML
<table ngGrid #grid="ngGrid">
@for (row of gridData; track row) {
<tr ngGridRow>
@for (cell of row; track cell) {
@let flipped = {value: false};
<td ngGridCell [rowSpan]="cell.rowSpan" [colSpan]="cell.colSpan">
<button
ngGridCellWidget
class="card"
[class.flipped]="flipped.value"
(click)="flipped.value = true"
>
<div class="card-face card-front">
<svg viewBox="0 0 222 245" xmlns="http://www.w3.org/2000/svg" class="angular-logo">
<path class="shield-shape" />
</svg>
</div>
<div class="card-face card-back">
<div [class.explode]="flipped.value && cell.explode">{{ cell.emoji }}</div>
</div>
</button>
</td>
}
</tr>
}
</table>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
--card-shadow: 2px 4px 6px rgba(0, 0, 0, 0.5);
}
button {
border: unset;
padding: unset;
color: unset;
background: unset;
outline: none;
}
[ngGrid] {
display: table;
border-spacing: 0.75rem;
}
[ngGridCell] {
height: 4rem;
width: 4rem;
perspective: 1000px;
}
.card {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
position: relative;
transform-style: preserve-3d;
transition: transform 0.3s ease-in-out;
cursor: pointer;
border-radius: 0.5rem;
border: 0.25rem solid #f0f0f0;
box-shadow: var(--card-shadow);
}
.card.flipped {
transform: rotateY(180deg);
cursor: default;
}
.card:not(.flipped):hover,
.card:not(.flipped):focus {
transform: scale(1.05) translate(-2px, -2px);
}
.card:hover,
.card:focus {
outline-offset: 2px;
outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}
.card-face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
border-radius: 0.25rem;
}
.card-front {
background-image: var(--hot-pink-to-electric-violet-radial-gradient);
color: rgba(0, 0, 0, 0.6);
overflow: hidden;
}
.card-back {
background: #f0f0f0;
transform: rotateY(180deg);
}
.explode {
animation: shake 0.25s 20 linear;
}
@keyframes shake {
0%,
100% {
transform: translate(0, 0) rotate(0deg);
}
20% {
transform: translate(-3px, -1px) rotate(-1deg);
}
40% {
transform: translate(3px, 1px) rotate(1deg);
}
60% {
transform: translate(-3px, 1px) rotate(-1deg);
}
80% {
transform: translate(3px, -1px) rotate(1deg);
}
}
.angular-logo {
transform: rotate(-25deg) scale(1.1) translateY(5%);
}
.shield-shape {
d: path(
'm 222.077 39.192 l -8.019 125.923 L 137.387 0 l 84.69 39.192 Z m -53.105 162.825 l -57.933 33.056 l -57.934 -33.056 l 11.783 -28.556 h 92.301 l 11.783 28.556 Z M 111.039 62.675 l 30.357 73.803 H 80.681 l 30.358 -73.803 Z M 7.937 165.115 L 0 39.192 L 84.69 0 L 7.937 165.115 Z'
);
fill: currentColor;
}
使用法
グリッドは、ユーザーが複数の方向へのキーボードナビゲーションを必要とする、行と列で構成されたデータやインタラクティブな要素に適しています。
次の場合にグリッドを使用します:
- 編集可能または選択可能なセルを持つインタラクティブなデータテーブルを構築する場合
- カレンダーや日付ピッカーを作成する場合
- スプレッドシートのようなインターフェースを実装する場合
- ページのタブストップを減らすために、インタラクティブな要素(ボタン、チェックボックス)をグループ化する場合
- 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.html',
styleUrl: 'app.css',
imports: [Grid, GridRow, GridCell, GridCellWidget, FormsModule],
})
export class App {
private readonly _headerCheckbox = viewChild<ElementRef<HTMLInputElement>>('headerCheckbox');
readonly allSelected = computed(() => this.data().every((t) => t.selected()));
readonly partiallySelected = computed(
() => !this.allSelected() && this.data().some((t) => t.selected()),
);
readonly data = signal<(Task & {selected: WritableSignal<boolean>})[]>([
{
selected: signal(false),
taskId: 101,
summary: 'Create Grid Aria Pattern',
priority: 'High',
assignee: 'Cyber Cat',
},
{
selected: signal(false),
taskId: 102,
summary: 'Build a Pill List example',
priority: 'Medium',
assignee: 'Caffeinated Owl',
},
{
selected: signal(false),
taskId: 103,
summary: 'Build a Calendar example',
priority: 'Medium',
assignee: 'Copybara',
},
{
selected: signal(false),
taskId: 104,
summary: 'Build a Data Table example',
priority: 'Low',
assignee: 'Rubber Duck',
},
{
selected: signal(false),
taskId: 105,
summary: 'Explore Grid possibilities',
priority: 'High',
assignee: '[Your Name Here]',
},
]);
sortAscending: boolean = true;
tempInput: string = '';
constructor() {
afterRenderEffect(() => {
this._headerCheckbox()!.nativeElement.indeterminate = this.partiallySelected();
});
}
startEdit(
event: KeyboardEvent | FocusEvent | undefined,
task: Task,
inputEl: HTMLInputElement,
): void {
this.tempInput = task.assignee;
inputEl.focus();
if (!(event instanceof KeyboardEvent)) return;
// Start editing with an alphanumeric character.
if (event.key.length === 1) {
this.tempInput = event.key;
}
}
onClickEdit(widget: GridCellWidget, task: Task, inputEl: HTMLInputElement) {
if (widget.isActivated()) return;
widget.activate();
setTimeout(() => this.startEdit(undefined, task, inputEl));
}
completeEdit(event: KeyboardEvent | FocusEvent | undefined, task: Task): void {
if (!(event instanceof KeyboardEvent)) {
return;
}
if (event.key === 'Enter') {
task.assignee = this.tempInput;
}
}
updateSelection(event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
this.data().forEach((t) => t.selected.set(checked));
}
sortTaskById(): void {
this.sortAscending = !this.sortAscending;
if (this.sortAscending) {
this.data.update((tasks) => tasks.sort((a, b) => a.taskId - b.taskId));
} else {
this.data.update((tasks) => tasks.sort((a, b) => b.taskId - a.taskId));
}
}
}
HTML
<table ngGrid class="basic-data-table">
<thead>
<tr ngGridRow>
<th ngGridCell>
<input
ngGridCellWidget
aria-label="Select all rows"
type="checkbox"
[checked]="allSelected()"
(change)="updateSelection($event)"
#headerCheckbox
/>
</th>
<th ngGridCell>
<button
ngGridCellWidget
class="sort-button"
aria-label="Sort by ID"
(click)="sortTaskById()"
>
ID
<span
aria-hidden="true"
class="material-symbols-outlined"
translate="no"
aria-hidden="true"
>
{{ sortAscending ? 'arrow_upward' : 'arrow_downward' }}
</span>
</button>
</th>
<th ngGridCell>Task</th>
<th ngGridCell>Priority</th>
<th ngGridCell>Assignee</th>
</tr>
</thead>
<tbody>
@for (task of data(); track task.taskId) {
<tr ngGridRow>
<td ngGridCell>
<input
ngGridCellWidget
aria-label="Select row {{ $index + 1 }}"
type="checkbox"
[(ngModel)]="task.selected"
/>
</td>
<td ngGridCell>{{ task.taskId }}</td>
<td ngGridCell>{{ task.summary }}</td>
<td ngGridCell>{{ task.priority }}</td>
<td ngGridCell class="assignee-cell">
<div
type="button"
ngGridCellWidget
aria-label="edit assignee"
widgetType="editable"
(activated)="startEdit($event, task, assigneeInput)"
(deactivated)="completeEdit($event, task)"
#widget="ngGridCellWidget"
>
<span [class.hidden]="widget.isActivated()">{{ task.assignee }}</span>
<input
[class.hidden]="!widget.isActivated()"
class="assignee-edit-input"
[(ngModel)]="tempInput"
#assigneeInput
/>
<button
tabindex="-1"
aria-label="edit assignee"
class="material-symbols-outlined assignee-edit-button"
(click)="onClickEdit(widget, task, assigneeInput)"
[class.hidden]="widget.isActivated()"
translate="no"
>
edit
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.hidden {
display: none;
}
button {
border: unset;
padding: unset;
color: unset;
background: unset;
outline: none;
}
input[type='checkbox'] {
accent-color: var(--electric-violet);
transform: scale(1.3);
outline: none;
cursor: pointer;
}
[ngGrid] {
display: table;
background-color: var(--septenary-contrast);
border-spacing: 0;
}
[ngGrid] th,
[ngGrid] td {
padding: 0.75rem 1rem;
}
thead {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
tbody {
background-color: var(--octonary-contrast);
}
tbody [ngGridRow]:focus-within,
tbody [ngGridRow]:hover {
background-color: var(--septenary-contrast);
}
[ngGridCell]:focus-within,
[ngGridCell]:hover {
outline-offset: -1px;
outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
.sort-button {
display: flex;
align-items: center;
cursor: pointer;
font-size: 1rem;
font-weight: 700;
}
.assignee-cell [ngGridCellWidget] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
}
.assignee-edit-button {
visibility: hidden;
cursor: pointer;
}
.assignee-cell:focus-within .assignee-edit-button,
.assignee-cell:hover .assignee-edit-button {
visibility: initial;
}
.assignee-edit-input {
outline: none;
border: none;
color: var(--full-contrast);
background-color: var(--page-background);
font-size: 1rem;
padding: 0.5rem;
}
TS
import {
afterRenderEffect,
Component,
computed,
ElementRef,
signal,
viewChild,
WritableSignal,
} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';
type Rank = 'S' | 'A' | 'B' | 'C';
interface Task {
reward: number;
target: string;
rank: Rank;
hunter: string;
}
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Grid, GridRow, GridCell, GridCellWidget, FormsModule],
})
export class App {
private readonly _headerCheckbox = viewChild<ElementRef<HTMLInputElement>>('headerCheckbox');
readonly allSelected = computed(() => this.data().every((t) => t.selected()));
readonly partiallySelected = computed(
() => !this.allSelected() && this.data().some((t) => t.selected()),
);
readonly data = signal<(Task & {selected: WritableSignal<boolean>})[]>([
{
selected: signal(false),
reward: 50,
target: '10 Goblins',
rank: 'C',
hunter: 'KB Smasher',
},
{
selected: signal(false),
reward: 999,
target: '1 Dragon',
rank: 'S',
hunter: 'Donkey',
},
{
selected: signal(false),
reward: 150,
target: '2 Trolls',
rank: 'B',
hunter: 'Meme Spammer',
},
{
selected: signal(false),
reward: 500,
target: '1 Demon',
rank: 'A',
hunter: 'Dante',
},
{
selected: signal(false),
reward: 10,
target: '5 Slimes',
rank: 'C',
hunter: '[Help Wanted]',
},
]);
sortAscending: boolean = true;
tempInput: string = '';
constructor() {
afterRenderEffect(() => {
this._headerCheckbox()!.nativeElement.indeterminate = this.partiallySelected();
});
}
startEdit(
event: KeyboardEvent | FocusEvent | undefined,
task: Task,
inputEl: HTMLInputElement,
): void {
this.tempInput = task.hunter;
inputEl.focus();
if (!(event instanceof KeyboardEvent)) return;
// Start editing with an alphanumeric character.
if (event.key.length === 1) {
this.tempInput = event.key;
}
}
onClickEdit(widget: GridCellWidget, task: Task, inputEl: HTMLInputElement) {
if (widget.isActivated()) return;
widget.activate();
setTimeout(() => this.startEdit(undefined, task, inputEl));
}
completeEdit(event: KeyboardEvent | FocusEvent | undefined, task: Task): void {
if (!(event instanceof KeyboardEvent)) {
return;
}
if (event.key === 'Enter') {
task.hunter = this.tempInput;
}
}
updateSelection(event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
this.data().forEach((t) => t.selected.set(checked));
}
sortTaskById(): void {
this.sortAscending = !this.sortAscending;
if (this.sortAscending) {
this.data.update((tasks) => tasks.sort((a, b) => a.reward - b.reward));
} else {
this.data.update((tasks) => tasks.sort((a, b) => b.reward - a.reward));
}
}
}
HTML
<table ngGrid class="retro-data-table">
<thead>
<tr ngGridRow>
<th ngGridCell>
<input
ngGridCellWidget
aria-label="Select all rows"
type="checkbox"
[checked]="allSelected()"
(change)="updateSelection($event)"
#headerCheckbox
/>
</th>
<th ngGridCell>
<button
ngGridCellWidget
class="sort-button"
aria-label="Sort by ID"
(click)="sortTaskById()"
>
Reward
<span
aria-hidden="true"
class="material-symbols-outlined"
translate="no"
aria-hidden="true"
>
{{ sortAscending ? 'arrow_upward' : 'arrow_downward' }}
</span>
</button>
</th>
<th ngGridCell>Target</th>
<th ngGridCell>Rank</th>
<th ngGridCell>Hunter</th>
</tr>
</thead>
<tbody>
@for (task of data(); track task) {
<tr ngGridRow>
<td ngGridCell>
<input
ngGridCellWidget
aria-label="Select row {{ $index + 1 }}"
type="checkbox"
[(ngModel)]="task.selected"
/>
</td>
<td ngGridCell>${{ task.reward }}</td>
<td ngGridCell>{{ task.target }}</td>
<td ngGridCell>{{ task.rank }}</td>
<td ngGridCell class="assignee-cell">
<div
type="button"
ngGridCellWidget
aria-label="edit hunter"
widgetType="editable"
(activated)="startEdit($event, task, assigneeInput)"
(deactivated)="completeEdit($event, task)"
#widget="ngGridCellWidget"
>
<span [class.hidden]="widget.isActivated()">{{ task.hunter }}</span>
<input
[class.hidden]="!widget.isActivated()"
class="assignee-edit-input"
[(ngModel)]="tempInput"
#assigneeInput
/>
<button
tabindex="-1"
aria-label="edit hunter"
class="material-symbols-outlined assignee-edit-button"
(click)="onClickEdit(widget, task, assigneeInput)"
[class.hidden]="widget.isActivated()"
translate="no"
>
edit
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--symbolic-yellow) 90%, var(--page-background));
--retro-button-text-color: color-mix(in srgb, var(--symbolic-yellow) 10%, white);
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.hidden {
display: none;
}
button {
border: unset;
padding: unset;
color: unset;
background: unset;
outline: none;
}
input[type='checkbox'] {
accent-color: var(--hot-pink);
transform: scale(1.3);
outline: none;
cursor: pointer;
}
[ngGrid] {
border-spacing: 0 0.5rem;
display: table;
}
[ngGrid] th,
[ngGrid] td {
padding: 0.5rem 0.75rem;
}
thead {
background-color: var(--retro-button-color);
color: var(--retro-button-text-color);
box-shadow: var(--retro-elevated-shadow);
}
tbody [ngGridRow]:focus-within,
tbody [ngGridRow]:hover {
background-color: var(--septenary-contrast);
}
[ngGridCell]:focus-within,
[ngGridCell]:hover {
outline-offset: 4px;
outline: 4px dashed color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
.sort-button {
display: flex;
align-items: center;
cursor: pointer;
font-family: 'Press Start 2P';
font-size: 1rem;
}
.assignee-cell [ngGridCellWidget] {
display: flex;
align-items: center;
justify-content: space-between;
outline: none;
}
.assignee-edit-button {
visibility: hidden;
cursor: pointer;
}
.assignee-cell:focus-within .assignee-edit-button,
.assignee-cell:hover .assignee-edit-button {
visibility: initial;
}
.assignee-edit-input {
outline: none;
border: none;
color: var(--full-contrast);
background-color: var(--page-background);
font-size: 1rem;
padding: 0.5rem;
}
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.html',
styleUrl: 'app.css',
providers: [provideNativeDateAdapter()],
imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App<D> {
private readonly _dayButtons = viewChildren(GridCellWidget);
private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
private readonly _firstWeekOffset = computed(() => {
const firstDayOfMonth = this._dateAdapter.createDate(
this._dateAdapter.getYear(this.viewMonth()),
this._dateAdapter.getMonth(this.viewMonth()),
1,
);
return (
(DAYS_PER_WEEK +
this._dateAdapter.getDayOfWeek(firstDayOfMonth) -
this._dateAdapter.getFirstDayOfWeek()) %
DAYS_PER_WEEK
);
});
protected readonly monthYearLabel = computed(() =>
this._dateAdapter
.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
.toLocaleUpperCase(),
);
protected readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
const prevMonthNumDays = this._dateAdapter.getNumDaysInMonth(
this._dateAdapter.addCalendarMonths(this.viewMonth(), -1),
);
const days: number[] = [];
for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
days.push(prevMonthNumDays - i);
}
return days;
});
readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');
const weekdays = longWeekdays.map((long, i) => {
return {long, narrow: narrowWeekdays[i]};
});
return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
});
/** The current selected date. */
readonly selectedDate: WritableSignal<D> = signal(this._dateAdapter.today());
/** The current display month. */
readonly viewMonth: WritableSignal<D> = signal(this.selectedDate());
/** Calendar day cells. */
readonly calendar = computed(() => {
const month = this.viewMonth();
const daysInMonth = this._dateAdapter.getNumDaysInMonth(month);
const dateNames = this._dateAdapter.getDateNames();
const calendar: CalendarCell[][] = [[]];
for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
if (cell == DAYS_PER_WEEK) {
calendar.push([]);
cell = 0;
}
const date = this._dateAdapter.createDate(
this._dateAdapter.getYear(month),
this._dateAdapter.getMonth(month),
i + 1,
);
const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);
calendar[calendar.length - 1].push({
displayName: dateNames[i],
ariaLabel,
date,
selected: signal(
this._dateAdapter.compareDate(
date,
untracked(() => this.selectedDate()),
) === 0,
),
day: i + 1,
});
}
return calendar;
});
nextMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
}
prevMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
}
scrollDown(): void {
this.nextMonth();
setTimeout(() => this._dayButtons()[0]?.element.focus());
}
scrollUp(): void {
this.prevMonth();
setTimeout(() => this._dayButtons()[this._dayButtons().length - 1]?.element.focus());
}
onKeyDown(event: KeyboardEvent): void {
const day = Number((event.target as Element).getAttribute('data-day'));
if (!day) return;
const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
if (day > 7 && day <= viewMonthNumDays - 7) return;
const arrowLeft = event.key === 'ArrowLeft';
const arrowRight = event.key === 'ArrowRight';
const arrowUp = event.key === 'ArrowUp';
const arrowDown = event.key === 'ArrowDown';
if ((day === 1 && arrowLeft) || (day <= 7 && arrowUp)) {
this.scrollUp();
}
if ((day === viewMonthNumDays && arrowRight) || (day > viewMonthNumDays - 7 && arrowDown)) {
this.scrollDown();
}
}
}
HTML
<div class="calendar basic-calendar">
<div class="calendar-header">
<button class="month-control" aria-label="previous month" (click)="prevMonth()">
<span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
>chevron_left</span
>
</button>
<h3>{{ monthYearLabel() }}</h3>
<button class="month-control" aria-label="next month" (click)="nextMonth()">
<span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
>chevron_right</span
>
</button>
</div>
<table
ngGrid
colWrap="continuous"
rowWrap="nowrap"
[enableSelection]="true"
[softDisabled]="false"
selectionMode="explicit"
(keydown)="onKeyDown($event)"
>
<thead>
<tr>
@for (day of weekdays(); track day.long) {
<th scope="col">
<span class="visually-hidden">{{ day.long }}</span>
<span aria-hidden="true">{{ day.narrow }}</span>
</th>
}
</tr>
</thead>
@for (week of calendar(); track week) {
<tr ngGridRow>
@if ($first) {
@for (day of daysFromPrevMonth(); track day) {
<td ngGridCell disabled>{{ day }}</td>
}
}
@for (day of week; track day) {
<td ngGridCell [(selected)]="day.selected">
<button ngGridCellWidget [attr.aria-label]="day.ariaLabel" [attr.data-day]="day.day">
{{ day.displayName }}
</button>
</td>
}
@if ($last && week.length < 7) {
@for (day of [].constructor(7 - week.length); track $index) {
<td ngGridCell disabled>{{ $index + 1 }}</td>
}
}
</tr>
}
</table>
</div>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.calendar {
display: flex;
flex-direction: column;
background-color: var(--septenary-contrast);
padding: 0.5rem;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-block-end: 0.5rem;
}
.calendar-header h3 {
margin: 0;
font-size: 1.2rem;
}
button {
border: unset;
padding: unset;
color: unset;
background: unset;
outline: none;
}
button:hover,
button:focus {
background-color: var(--senary-contrast);
}
button:focus {
outline-offset: -1px;
outline: 1px solid color-mix(in srgb, var(--hot-pink) 60%, transparent);
}
.visually-hidden {
clip: rect(1px, 1px, 1px, 1px);
height: 1px;
width: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
}
.month-control {
width: 45px;
height: 45px;
cursor: pointer;
}
[ngGrid] {
display: table;
border-spacing: 0;
}
[ngGridCell] {
width: 50px;
height: 50px;
text-align: center;
vertical-align: middle;
}
[ngGridCell][aria-selected='true'] > button[ngGridCellWidget] {
background-color: var(--electric-violet);
color: var(--octonary-contrast);
}
[ngGridCell][aria-disabled='true'] {
color: var(--senary-contrast);
}
thead {
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
button[ngGridCellWidget] {
width: 45px;
height: 45px;
cursor: pointer;
}
TS
import {
Component,
computed,
inject,
signal,
Signal,
untracked,
viewChildren,
WritableSignal,
} from '@angular/core';
import {
DateAdapter,
MAT_DATE_FORMATS,
MatDateFormats,
provideNativeDateAdapter,
} from '@angular/material/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';
const DAYS_PER_WEEK = 7;
interface CalendarCell<D = any> {
displayName: string;
ariaLabel: string;
date: D;
selected: WritableSignal<boolean>;
day: number;
}
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
providers: [provideNativeDateAdapter()],
imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App<D> {
private readonly _dayButtons = viewChildren(GridCellWidget);
private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
private readonly _firstWeekOffset = computed(() => {
const firstDayOfMonth = this._dateAdapter.createDate(
this._dateAdapter.getYear(this.viewMonth()),
this._dateAdapter.getMonth(this.viewMonth()),
1,
);
return (
(DAYS_PER_WEEK +
this._dateAdapter.getDayOfWeek(firstDayOfMonth) -
this._dateAdapter.getFirstDayOfWeek()) %
DAYS_PER_WEEK
);
});
protected readonly monthYearLabel = computed(() =>
this._dateAdapter
.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
.toLocaleUpperCase(),
);
protected readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
const prevMonthNumDays = this._dateAdapter.getNumDaysInMonth(
this._dateAdapter.addCalendarMonths(this.viewMonth(), -1),
);
const days: number[] = [];
for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
days.push(prevMonthNumDays - i);
}
return days;
});
readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');
const weekdays = longWeekdays.map((long, i) => {
return {long, narrow: narrowWeekdays[i]};
});
return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
});
/** The current selected date. */
readonly selectedDate: WritableSignal<D> = signal(this._dateAdapter.today());
/** The current display month. */
readonly viewMonth: WritableSignal<D> = signal(this.selectedDate());
/** Calendar day cells. */
readonly calendar = computed(() => {
const month = this.viewMonth();
const daysInMonth = this._dateAdapter.getNumDaysInMonth(month);
const dateNames = this._dateAdapter.getDateNames();
const calendar: CalendarCell[][] = [[]];
for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
if (cell == DAYS_PER_WEEK) {
calendar.push([]);
cell = 0;
}
const date = this._dateAdapter.createDate(
this._dateAdapter.getYear(month),
this._dateAdapter.getMonth(month),
i + 1,
);
const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);
calendar[calendar.length - 1].push({
displayName: dateNames[i],
ariaLabel,
date,
selected: signal(
this._dateAdapter.compareDate(
date,
untracked(() => this.selectedDate()),
) === 0,
),
day: i + 1,
});
}
return calendar;
});
nextMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
}
prevMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
}
scrollDown(): void {
this.nextMonth();
setTimeout(() => this._dayButtons()[0]?.element.focus());
}
scrollUp(): void {
this.prevMonth();
setTimeout(() => this._dayButtons()[this._dayButtons().length - 1]?.element.focus());
}
onKeyDown(event: KeyboardEvent): void {
const day = Number((event.target as Element).getAttribute('data-day'));
if (!day) return;
const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
if (day > 7 && day <= viewMonthNumDays - 7) return;
const arrowLeft = event.key === 'ArrowLeft';
const arrowRight = event.key === 'ArrowRight';
const arrowUp = event.key === 'ArrowUp';
const arrowDown = event.key === 'ArrowDown';
if ((day === 1 && arrowLeft) || (day <= 7 && arrowUp)) {
this.scrollUp();
}
if ((day === viewMonthNumDays && arrowRight) || (day > viewMonthNumDays - 7 && arrowDown)) {
this.scrollDown();
}
}
}
HTML
<div class="calendar material-calendar">
<div class="calendar-header">
<button class="month-control" aria-label="previous month" (click)="prevMonth()">
<span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
>chevron_left</span
>
</button>
<h3>{{ monthYearLabel() }}</h3>
<button class="month-control" aria-label="next month" (click)="nextMonth()">
<span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
>chevron_right</span
>
</button>
</div>
<table
ngGrid
colWrap="continuous"
rowWrap="nowrap"
[enableSelection]="true"
[softDisabled]="false"
selectionMode="explicit"
(keydown)="onKeyDown($event)"
>
<thead>
<tr>
@for (day of weekdays(); track day.long) {
<th scope="col">
<span class="visually-hidden">{{ day.long }}</span>
<span aria-hidden="true">{{ day.narrow }}</span>
</th>
}
</tr>
</thead>
@for (week of calendar(); track week) {
<tr ngGridRow>
@if ($first) {
@for (day of daysFromPrevMonth(); track day) {
<td ngGridCell disabled>{{ day }}</td>
}
}
@for (day of week; track day) {
<td ngGridCell [(selected)]="day.selected">
<button ngGridCellWidget [attr.aria-label]="day.ariaLabel" [attr.data-day]="day.day">
{{ day.displayName }}
</button>
</td>
}
@if ($last && week.length < 7) {
@for (day of [].constructor(7 - week.length); track $index) {
<td ngGridCell disabled>{{ $index + 1 }}</td>
}
}
</tr>
}
</table>
</div>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
.calendar {
display: flex;
flex-direction: column;
background-color: var(--septenary-contrast);
border-radius: 0.5rem;
padding: 0.5rem;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-block-end: 0.5rem;
}
.calendar-header h3 {
margin: 0;
font-size: 1.2rem;
}
button {
border: unset;
padding: unset;
color: unset;
background: unset;
outline: none;
border-radius: 50%;
}
button:hover,
button:focus {
background-color: var(--senary-contrast);
}
button:focus {
outline-offset: -1px;
outline: 1px solid color-mix(in srgb, var(--bright-blue) 60%, transparent);
}
.visually-hidden {
clip: rect(1px, 1px, 1px, 1px);
height: 1px;
width: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
}
.month-control {
width: 45px;
height: 45px;
cursor: pointer;
}
[ngGrid] {
display: table;
border-spacing: 0;
}
[ngGridCell] {
width: 50px;
height: 50px;
text-align: center;
vertical-align: middle;
}
[ngGridCell][aria-selected='true'] > button[ngGridCellWidget] {
background-color: var(--indigo-blue);
color: var(--octonary-contrast);
}
[ngGridCell][aria-disabled='true'] {
color: var(--senary-contrast);
}
thead {
color: var(--secondary-contrast);
}
button[ngGridCellWidget] {
width: 45px;
height: 45px;
cursor: pointer;
}
TS
import {
Component,
computed,
inject,
signal,
Signal,
untracked,
viewChildren,
WritableSignal,
} from '@angular/core';
import {
DateAdapter,
MAT_DATE_FORMATS,
MatDateFormats,
provideNativeDateAdapter,
} from '@angular/material/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';
const DAYS_PER_WEEK = 7;
interface CalendarCell<D = any> {
displayName: string;
ariaLabel: string;
date: D;
selected: WritableSignal<boolean>;
day: number;
}
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
providers: [provideNativeDateAdapter()],
imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App<D> {
private readonly _dayButtons = viewChildren(GridCellWidget);
private readonly _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
private readonly _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
private readonly _firstWeekOffset = computed(() => {
const firstDayOfMonth = this._dateAdapter.createDate(
this._dateAdapter.getYear(this.viewMonth()),
this._dateAdapter.getMonth(this.viewMonth()),
1,
);
return (
(DAYS_PER_WEEK +
this._dateAdapter.getDayOfWeek(firstDayOfMonth) -
this._dateAdapter.getFirstDayOfWeek()) %
DAYS_PER_WEEK
);
});
protected readonly monthYearLabel = computed(() =>
this._dateAdapter
.format(this.viewMonth(), this._dateFormats.display.monthYearLabel)
.toLocaleUpperCase(),
);
protected readonly daysFromPrevMonth: Signal<number[]> = computed(() => {
const prevMonthNumDays = this._dateAdapter.getNumDaysInMonth(
this._dateAdapter.addCalendarMonths(this.viewMonth(), -1),
);
const days: number[] = [];
for (let i = this._firstWeekOffset() - 1; i >= 0; i--) {
days.push(prevMonthNumDays - i);
}
return days;
});
readonly weekdays: Signal<{long: string; narrow: string}[]> = computed(() => {
const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow');
const longWeekdays = this._dateAdapter.getDayOfWeekNames('long');
const weekdays = longWeekdays.map((long, i) => {
return {long, narrow: narrowWeekdays[i]};
});
return weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek));
});
/** The current selected date. */
readonly selectedDate: WritableSignal<D> = signal(this._dateAdapter.today());
/** The current display month. */
readonly viewMonth: WritableSignal<D> = signal(this.selectedDate());
/** Calendar day cells. */
readonly calendar = computed(() => {
const month = this.viewMonth();
const daysInMonth = this._dateAdapter.getNumDaysInMonth(month);
const dateNames = this._dateAdapter.getDateNames();
const calendar: CalendarCell[][] = [[]];
for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) {
if (cell == DAYS_PER_WEEK) {
calendar.push([]);
cell = 0;
}
const date = this._dateAdapter.createDate(
this._dateAdapter.getYear(month),
this._dateAdapter.getMonth(month),
i + 1,
);
const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel);
calendar[calendar.length - 1].push({
displayName: dateNames[i],
ariaLabel,
date,
selected: signal(
this._dateAdapter.compareDate(
date,
untracked(() => this.selectedDate()),
) === 0,
),
day: i + 1,
});
}
return calendar;
});
nextMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), 1));
}
prevMonth(): void {
this.viewMonth.set(this._dateAdapter.addCalendarMonths(this.viewMonth(), -1));
}
scrollDown(): void {
this.nextMonth();
setTimeout(() => this._dayButtons()[0]?.element.focus());
}
scrollUp(): void {
this.prevMonth();
setTimeout(() => this._dayButtons()[this._dayButtons().length - 1]?.element.focus());
}
onKeyDown(event: KeyboardEvent): void {
const day = Number((event.target as Element).getAttribute('data-day'));
if (!day) return;
const viewMonthNumDays = this._dateAdapter.getNumDaysInMonth(this.viewMonth());
if (day > 7 && day <= viewMonthNumDays - 7) return;
const arrowLeft = event.key === 'ArrowLeft';
const arrowRight = event.key === 'ArrowRight';
const arrowUp = event.key === 'ArrowUp';
const arrowDown = event.key === 'ArrowDown';
if ((day === 1 && arrowLeft) || (day <= 7 && arrowUp)) {
this.scrollUp();
}
if ((day === viewMonthNumDays && arrowRight) || (day > viewMonthNumDays - 7 && arrowDown)) {
this.scrollDown();
}
}
}
HTML
<div class="calendar retro-calendar">
<div class="calendar-header">
<button class="month-control" aria-label="previous month" (click)="prevMonth()">
<span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
>chevron_left</span
>
</button>
<h3>{{ monthYearLabel() }}</h3>
<button class="month-control" aria-label="next month" (click)="nextMonth()">
<span aria-hidden="true" class="material-symbols-outlined" translate="no" aria-hidden="true"
>chevron_right</span
>
</button>
</div>
<table
ngGrid
colWrap="continuous"
rowWrap="nowrap"
[enableSelection]="true"
[softDisabled]="false"
selectionMode="explicit"
(keydown)="onKeyDown($event)"
>
<thead>
<tr>
@for (day of weekdays(); track day.long) {
<th scope="col">
<span class="visually-hidden">{{ day.long }}</span>
<span aria-hidden="true">{{ day.narrow }}</span>
</th>
}
</tr>
</thead>
@for (week of calendar(); track week) {
<tr ngGridRow>
@if ($first) {
@for (day of daysFromPrevMonth(); track day) {
<td ngGridCell disabled>{{ day }}</td>
}
}
@for (day of week; track day) {
<td ngGridCell [(selected)]="day.selected">
<button ngGridCellWidget [attr.aria-label]="day.ariaLabel" [attr.data-day]="day.day">
{{ day.displayName }}
</button>
</td>
}
@if ($last && week.length < 7) {
@for (day of [].constructor(7 - week.length); track $index) {
<td ngGridCell disabled>{{ $index + 1 }}</td>
}
}
</tr>
}
</table>
</div>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--always-pink) 90%, var(--page-background));
--retro-button-text-color: color-mix(in srgb, var(--always-pink) 10%, white);
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
.calendar {
display: flex;
flex-direction: column;
background-color: var(--septenary-contrast);
padding: 0.5rem;
box-shadow: var(--retro-flat-shadow);
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-block-end: 0.5rem;
}
.calendar-header h3 {
margin: 0;
font-size: 1.2rem;
}
button {
font-family: 'Press Start 2P';
border: unset;
padding: unset;
color: unset;
background: unset;
outline: none;
}
button:hover,
button:focus {
background-color: var(--senary-contrast);
}
button:focus {
outline-offset: 4px;
outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}
.visually-hidden {
clip: rect(1px, 1px, 1px, 1px);
height: 1px;
width: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
}
.month-control {
width: 45px;
height: 45px;
cursor: pointer;
}
[ngGrid] {
display: table;
border-spacing: 0;
}
[ngGridCell] {
width: 50px;
height: 50px;
text-align: center;
vertical-align: middle;
}
[ngGridCell][aria-selected='true'] > button[ngGridCellWidget] {
background-color: var(--retro-button-color);
color: var(--retro-button-text-color);
box-shadow: var(--retro-clickable-shadow);
}
[ngGridCell][aria-disabled='true'] {
color: var(--senary-contrast);
}
thead {
background-image: var(--orange-to-pink-vertical-gradient);
background-clip: text;
color: transparent;
}
button[ngGridCellWidget] {
width: 45px;
height: 45px;
cursor: pointer;
}
ユーザーは、セルにフォーカスが当たっているときにEnterキーまたはSpaceキーを押すことで、日付をアクティブにできます。
レイアウトグリッド
レイアウトグリッドを使用して、インタラクティブな要素をグループ化し、タブストップを減らします。この例は、ピルボタンのグリッドを示しています。
TS
import {Component, signal} from '@angular/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
tags = signal(['Unleash', 'Your', 'Creativity', 'With', 'Angular', 'Aria']);
removeTag(index: number) {
this.tags.update((tags) => [...tags.slice(0, index), ...tags.slice(index + 1)]);
}
}
HTML
<div ngGrid colWrap="continuous" class="basic-pill-list">
@for (tag of tags(); track $index) {
<div ngGridRow>
<span ngGridCell>#{{ tag }}</span>
<span ngGridCell>
<button
ngGridCellWidget
aria-label="remove tag"
class="material-symbols-outlined"
(click)="removeTag($index)"
translate="no"
>
close
</button>
</span>
</div>
}
</div>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
[ngGrid] {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
max-width: 400px;
background-image: var(--pink-to-purple-horizontal-gradient);
background-clip: text;
color: transparent;
}
[ngGridRow] {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px dotted var(--senary-contrast);
padding: 0 0.25rem 0 0.75rem;
}
[ngGridRow]:focus-within,
[ngGridRow]:hover {
outline-offset: -1px;
outline: 1px solid color-mix(in srgb, var(--vivid-pink) 80%, transparent);
}
[ngGridRow]:has(button[ngGridCellWidget]:focus),
[ngGridRow]:has(button[ngGridCellWidget]:hover) {
outline: none;
}
[ngGridCell] {
display: flex;
outline: none;
}
button[ngGridCellWidget] {
border: unset;
padding: unset;
color: unset;
background: unset;
font-size: 1.2rem;
width: 1.5rem;
height: 1.5rem;
margin: 0.25rem;
border-radius: 50%;
cursor: pointer;
}
button[ngGridCellWidget]:focus,
button[ngGridCellWidget]:hover {
outline: 1px solid color-mix(in srgb, var(--vivid-pink) 80%, transparent);
}
TS
import {Component, signal} from '@angular/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
tags = signal(['Unleash', 'Your', 'Creativity', 'With', 'Angular', 'Aria']);
removeTag(index: number) {
this.tags.update((tags) => [...tags.slice(0, index), ...tags.slice(index + 1)]);
}
}
HTML
<div ngGrid colWrap="continuous" class="material-pill-list">
@for (tag of tags(); track $index) {
<div ngGridRow>
<span ngGridCell>{{ tag }}</span>
<span ngGridCell>
<button
ngGridCellWidget
aria-label="remove tag"
class="material-symbols-outlined"
(click)="removeTag($index)"
translate="no"
>
close
</button>
</span>
</div>
}
</div>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
:host {
display: flex;
justify-content: center;
font-family: var(--inter-font);
}
[ngGrid] {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
max-width: 400px;
}
[ngGridRow] {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--senary-contrast);
border-radius: 0.5rem;
padding: 0 0.25rem 0 0.75rem;
}
[ngGridRow]:focus-within,
[ngGridRow]:hover {
background-color: var(--senary-contrast);
}
[ngGridRow]:has(button[ngGridCellWidget]:focus),
[ngGridRow]:has(button[ngGridCellWidget]:hover) {
background-color: initial;
}
[ngGridCell] {
display: flex;
outline: none;
}
button[ngGridCellWidget] {
border: unset;
padding: unset;
color: unset;
background: unset;
font-size: 1.2rem;
width: 1.5rem;
height: 1.5rem;
margin: 0.25rem;
border-radius: 50%;
cursor: pointer;
}
button[ngGridCellWidget]:focus,
button[ngGridCellWidget]:hover {
outline: none;
background-color: var(--septenary-contrast);
}
TS
import {Component, signal} from '@angular/core';
import {Grid, GridRow, GridCell, GridCellWidget} from '@angular/aria/grid';
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Grid, GridRow, GridCell, GridCellWidget],
})
export class App {
tags = signal(['Unleash', 'Your', 'Creativity', 'With', 'Angular', 'Aria']);
removeTag(index: number) {
this.tags.update((tags) => [...tags.slice(0, index), ...tags.slice(index + 1)]);
}
}
HTML
<div ngGrid colWrap="continuous" class="retro-pill-list">
@for (tag of tags(); track $index) {
<div ngGridRow>
<span ngGridCell>#{{ tag }}</span>
<span ngGridCell>
<button
ngGridCellWidget
aria-label="remove tag"
class="material-symbols-outlined"
(click)="removeTag($index)"
translate="no"
>
close
</button>
</span>
</div>
}
</div>
CSS
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
:host {
display: flex;
justify-content: center;
font-family: 'Press Start 2P';
--retro-button-color: color-mix(in srgb, var(--always-pink) 90%, var(--page-background));
--retro-button-text-color: color-mix(in srgb, var(--always-pink) 10%, white);
--retro-shadow-light: color-mix(in srgb, var(--retro-button-color) 90%, #fff);
--retro-shadow-dark: color-mix(in srgb, var(--retro-button-color) 90%, #000);
--retro-elevated-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast);
--retro-flat-shadow:
4px 0px 0px 0px var(--tertiary-contrast), 0px 4px 0px 0px var(--tertiary-contrast),
-4px 0px 0px 0px var(--tertiary-contrast), 0px -4px 0px 0px var(--tertiary-contrast);
--retro-clickable-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-light),
inset -4px -4px 0px 0px var(--retro-shadow-dark), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 8px 8px 0px 0px var(--tertiary-contrast);
--retro-pressed-shadow:
inset 4px 4px 0px 0px var(--retro-shadow-dark),
inset -4px -4px 0px 0px var(--retro-shadow-light), 4px 0px 0px 0px var(--tertiary-contrast),
0px 4px 0px 0px var(--tertiary-contrast), -4px 0px 0px 0px var(--tertiary-contrast),
0px -4px 0px 0px var(--tertiary-contrast), 0px 0px 0px 0px var(--tertiary-contrast);
}
[ngGrid] {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
max-width: 400px;
}
[ngGridRow] {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.25rem 0 0.75rem;
color: var(--retro-button-text-color);
background-color: var(--retro-button-color);
box-shadow: var(--retro-clickable-shadow);
}
[ngGridRow]:focus-within,
[ngGridRow]:hover {
outline-offset: 4px;
outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}
[ngGridRow]:has(button[ngGridCellWidget]:focus),
[ngGridRow]:has(button[ngGridCellWidget]:hover) {
outline: none;
}
[ngGridCell] {
display: flex;
outline: none;
}
button[ngGridCellWidget] {
border: unset;
padding: unset;
color: unset;
background: unset;
font-size: 1.5rem;
margin: 0.25rem;
cursor: pointer;
}
button[ngGridCellWidget]:focus,
button[ngGridCellWidget]:hover {
outline-offset: 8px;
outline: 4px dashed color-mix(in srgb, var(--hot-pink) 90%, transparent);
}
各ボタンをタブで移動する代わりに、ユーザーは矢印キーで移動し、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> |
セルが現在フォーカスを持っているかどうか |