Vue d'ensemble
Une grid permet aux utilisateurs de naviguer dans des données ou des éléments interactifs à deux dimensions à l'aide des touches fléchées, Home, End et Page Up/Down. Les grids conviennent aux tableaux de données, aux calendriers, aux feuilles de calcul et aux mises en page qui regroupent des éléments interactifs liés.
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;
}
Utilisation
Les grids conviennent bien aux données ou aux éléments interactifs organisés en lignes et en colonnes, lorsque les utilisateurs ont besoin de naviguer au clavier dans plusieurs directions.
Utilisez une grid pour :
- Construire des tableaux de données interactifs avec des cellules modifiables ou sélectionnables
- Créer des calendriers ou des sélecteurs de date
- Implémenter des interfaces de type feuille de calcul
- Regrouper des éléments interactifs (boutons, cases à cocher) afin de réduire le nombre d'arrêts de tabulation sur une page
- Construire des interfaces qui nécessitent une navigation au clavier à deux dimensions
Évitez les grids pour :
- Afficher de simples tableaux en lecture seule (utilisez plutôt l'élément HTML sémantique
<table>) - Afficher des listes à une seule colonne (utilisez plutôt une listbox)
- Afficher des données hiérarchiques (utilisez plutôt un tree)
- Construire des formulaires sans mise en page tabulaire (utilisez des contrôles de formulaire standards)
Fonctionnalités
- Navigation à deux dimensions - Les touches fléchées déplacent le focus entre les cellules dans toutes les directions
- Modes de focus - Choisissez entre les stratégies de focus roving tabindex ou activedescendant
- Prise en charge de la sélection - Sélection de cellules facultative, en mode simple ou multiple
- Comportement de bouclage - Configurez la façon dont la navigation boucle aux bords de la grid (continuous, loop ou nowrap)
- Sélection de plage - Sélectionnez plusieurs cellules avec les touches de modification ou par glissement
- États désactivés - Désactivez la grid entière ou des cellules individuelles
- Prise en charge du RTL - Navigation automatique pour les langues de droite à gauche
Exemples
Grid de tableau de données
Utilisez une grid pour les tableaux interactifs dans lesquels les utilisateurs doivent naviguer entre les cellules avec les touches fléchées. Cet exemple montre un tableau de données simple avec navigation au clavier.
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">
{{ 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">
{{ 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;
}
Appliquez la directive ngGrid à l'élément du tableau, ngGridRow à chaque ligne et ngGridCell à chaque cellule.
Grid de calendrier
Les calendriers sont un cas d'usage courant des grids. Cet exemple montre une vue mensuelle dans laquelle les utilisateurs naviguent entre les dates avec les touches fléchées.
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">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.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">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.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">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(--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;
}
Les utilisateurs peuvent activer une date en appuyant sur Entrée ou Espace lorsque le focus est sur une cellule.
Grid de mise en page
Utilisez une grid de mise en page pour regrouper des éléments interactifs et réduire le nombre d'arrêts de tabulation. Cet exemple montre une grid de boutons en forme de pastilles.
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);
}
Au lieu de parcourir chaque bouton avec Tab, les utilisateurs naviguent avec les touches fléchées et un seul bouton reçoit le focus de tabulation.
Modes de sélection et de focus
Activez la sélection avec [enableSelection]="true" et configurez la façon dont le focus et la sélection interagissent.
<table
ngGrid
[enableSelection]="true"
[selectionMode]="'explicit'"
[multi]="true"
[focusMode]="'roving'"
>
<tr ngGridRow>
<td ngGridCell>Cell 1</td>
<td ngGridCell>Cell 2</td>
</tr>
</table>
Modes de sélection :
follow: la cellule qui a le focus est automatiquement sélectionnéeexplicit: les utilisateurs sélectionnent les cellules avec Espace ou par un clic
Modes de focus :
roving: le focus se déplace sur les cellules à l'aide detabindex(préférable pour les grids simples)activedescendant: le focus reste sur le conteneur de la grid, etaria-activedescendantindique la cellule active (préférable pour le défilement virtuel)
Tests
Angular Aria fournit des harnais de composant pour tester les composants grid. Voici un exemple d'utilisation des harnais dans un test de composant :
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {GridHarness} from '@angular/aria/grid/testing';
import {MyGridComponent} from './my-grid'; // Your component
describe('MyGridComponent', () => {
let fixture: ComponentFixture<MyGridComponent>;
let loader: HarnessLoader;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [MyGridComponent],
});
fixture = TestBed.createComponent(MyGridComponent);
await fixture.whenStable();
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('should read cell values and focus cells', async () => {
const grid = await loader.getHarness(GridHarness);
// Get all cells text in a 2D array organized by rows
const cellTexts = await grid.getCellTextByIndex();
expect(cellTexts).toEqual([
['Cell 1.1', 'Cell 1.2'],
['Cell 2.1', 'Cell 2.2'],
]);
// Get a specific cell by text
const cells = await grid.getCells({text: 'Cell 1.1'});
expect(cells.length).toBe(1);
const cell = cells[0];
// Verify cell state
expect(await cell.isSelected()).toBe(true);
expect(await cell.isActive()).toBe(true);
// Focus the cell
await cell.focus();
expect(await cell.isFocused()).toBe(true);
});
});
Référence de l'API
Pour une documentation détaillée de l'API, consultez les références suivantes :