Grid
Genel Bakış
Bir grid, kullanıcıların yönlü ok tuşları, Home, End ve Page Up/Down kullanarak iki boyutlu veriler veya etkileşimli elemanlar arasında gezinmesini sağlar. Gridler veri tabloları, takvimler, hesap tabloları ve ilişkili etkileşimli elemanları gruplayan yerleşim kalıpları için çalışır.
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;
}
Kullanım
Gridler, satırlar ve sütunlar halinde düzenlenmiş ve kullanıcıların birden fazla yönde klavye navigasyonuna ihtiyaç duydukları veriler veya etkileşimli elemanlar için iyi çalışır.
Grid kullanın:
- Düzenlenebilir veya seçilebilir hücrelere sahip etkileşimli veri tabloları oluştururken
- Takvim veya tarih seçiciler oluştururken
- Hesap tablosu benzeri arayüzler uygularken
- Bir sayfadaki sekme duraklarını azaltmak için etkileşimli elemanları (butonlar, onay kutuları) gruplarken
- İki boyutlu klavye navigasyonu gerektiren arayüzler oluştururken
Gridlerden kaçının:
- Basit salt okunur tablolar gösterirken (bunun yerine semantik HTML
<table>kullanın) - Tek sütunlu listeler gösterirken (bunun yerine Listbox kullanın)
- Hiyerarşik veri gösterirken (bunun yerine Tree kullanın)
- Tablo yerleşimi olmayan formlar oluştururken (standart form kontrolleri kullanın)
Özellikler
- İki boyutlu navigasyon - Ok tuşları tüm yönlerde hücreler arasında hareket eder
- Odak modları - Dolaşan tabindex veya activedescendant odak stratejileri arasında seçim yapın
- Seçim desteği - Tekli veya çoklu seçim modlarıyla isteğe bağlı hücre seçimi
- Sarma davranışı - Grid kenarlarında navigasyonun nasıl sarılacağını yapılandırın (sürekli, döngü veya sarmasız)
- Aralık seçimi - Değiştirici tuşlar veya sürüklemeyle birden fazla hücre seçin
- Devre dışı durumlar - Tüm gridi veya bireysel hücreleri devre dışı bırakın
- RTL desteği - Otomatik sağdan sola dil navigasyonu
Örnekler
Veri Tablosu Gridi
Kullanıcıların ok tuşlarıyla hücreler arasında gezinmesi gereken etkileşimli tablolar için grid kullanın. Bu örnek, klavye navigasyonlu temel bir veri tablosunu gösterir.
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 yönergesini tablo elemanına, ngGridRow'u her satıra ve ngGridCell'i her hücreye uygulayın.
Takvim Gridi
Takvimler, gridler için yaygın bir kullanım durumudur. Bu örnek, kullanıcıların ok tuşlarıyla tarihler arasında gezdiği bir ay görünümünü gösterir.
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;
}
Kullanıcılar bir hücreye odaklandığında Enter veya Boşluk tuşuna basarak bir tarihi etkinleştirebilir.
Yerleşim Gridi
Etkileşimli elemanları gruplamak ve sekme duraklarını azaltmak için yerleşim gridi kullanın. Bu örnek, hap butonlarından oluşan bir gridi gösterir.
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);
}
Her buton arasında sekme tuşuna basmak yerine, kullanıcılar ok tuşlarıyla gezinir ve yalnızca bir buton sekme odağını alır.
Seçim ve Odak Modları
[enableSelection]="true" ile seçimi etkinleştirin ve odak ile seçimin nasıl etkileşeceğini yapılandırın.
<table
ngGrid
[enableSelection]="true"
[selectionMode]="'explicit'"
[multi]="true"
[focusMode]="'roving'"
>
<tr ngGridRow>
<td ngGridCell>Cell 1</td>
<td ngGridCell>Cell 2</td>
</tr>
</table>
Seçim modları:
follow: Odaklanan hücre otomatik olarak seçilirexplicit: Kullanıcılar hücreleri Boşluk veya tıklama ile seçer
Odak modları:
roving: Odak,tabindexkullanılarak hücrelere hareket eder (basit gridler için daha iyidir)activedescendant: Odak grid kapsayıcısında kalır,aria-activedescendantaktif hücreyi gösterir (sanal kaydırma için daha iyidir)
Test Etme
Angular Aria, grid bileşenlerini test etmek için bileşen harness'leri sağlar. Harness'lerin bir bileşen testinde nasıl kullanılacağına dair bir örnek:
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'; // Bileşeniniz
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);
// Satırlara göre düzenlenmiş 2 boyutlu bir dizide tüm hücre metinlerini al
const cellTexts = await grid.getCellTextByIndex();
expect(cellTexts).toEqual([
['Cell 1.1', 'Cell 1.2'],
['Cell 2.1', 'Cell 2.2'],
]);
// Metnine göre belirli bir hücreyi al
const cells = await grid.getCells({text: 'Cell 1.1'});
expect(cells.length).toBe(1);
const cell = cells[0];
// Hücre durumunu doğrula
expect(await cell.isSelected()).toBe(true);
expect(await cell.isActive()).toBe(true);
// Hücreye odaklan
await cell.focus();
expect(await cell.isFocused()).toBe(true);
});
});
API'ler
Grid
Satırlar ve hücreler için klavye navigasyonu ve odak yönetimi sağlayan kapsayıcı yönerge.
Girdiler
| Property | Type | Default | Description |
|---|---|---|---|
enableSelection |
boolean |
false |
Grid için seçimin etkin olup olmadığı |
disabled |
boolean |
false |
Tüm gridi devre dışı bırakır |
softDisabled |
boolean |
true |
true olduğunda, devre dışı hücreler odaklanabilir ancak etkileşimli değildir |
focusMode |
'roving' | 'activedescendant' |
'roving' |
Grid tarafından kullanılan odak stratejisi |
rowWrap |
'continuous' | 'loop' | 'nowrap' |
'loop' |
Satırlar boyunca navigasyon sarma davranışı |
colWrap |
'continuous' | 'loop' | 'nowrap' |
'loop' |
Sütunlar boyunca navigasyon sarma davranışı |
multi |
boolean |
false |
Birden fazla hücrenin seçilebilip seçilemeyeceği |
selectionMode |
'follow' | 'explicit' |
'follow' |
Seçimin odağı takip edip etmediği veya açık eylem gerektirip gerektirmediği |
enableRangeSelection |
boolean |
false |
Değiştirici tuşlar veya sürüklemeyle aralık seçimlerini etkinleştirir |
GridRow
Bir grid içindeki bir satırı temsil eder ve grid hücreleri için kapsayıcı görevi görür.
Girdiler
| Property | Type | Default | Description |
|---|---|---|---|
rowIndex |
number |
auto | Bu satırın grid içindeki indeksi |
GridCell
Bir grid satırındaki bireysel bir hücreyi temsil eder.
Girdiler
| Property | Type | Default | Description |
|---|---|---|---|
id |
string |
auto | Hücre için benzersiz tanımlayıcı |
role |
string |
'gridcell' |
Hücre rolü: gridcell, columnheader veya rowheader |
disabled |
boolean |
false |
Bu hücreyi devre dışı bırakır |
selected |
boolean |
false |
Hücrenin seçili olup olmadığı (iki yönlü bağlama destekler) |
selectable |
boolean |
true |
Hücrenin seçilebilir olup olmadığı |
rowSpan |
number |
— | Hücrenin kapladığı satır sayısı |
colSpan |
number |
— | Hücrenin kapladığı sütun sayısı |
rowIndex |
number |
— | Hücrenin satır indeksi |
colIndex |
number |
— | Hücrenin sütun indeksi |
orientation |
'vertical' | 'horizontal' |
'horizontal' |
Hücre içindeki widget'lar için yön |
wrap |
boolean |
true |
Widget navigasyonunun hücre içinde sarılıp sarılmadığı |
Sinyaller
| Property | Type | Description |
|---|---|---|
active |
Signal<boolean> |
Hücrenin şu anda odakta olup olmadığı |
GridCellWidget
Grid navigasyonunun duraklatılmasına izin vermek için bir grid hücresi içindeki etkileşimli bir öğeye uygulanır.
Girdiler
| Property | Type | Default | Description |
|---|---|---|---|
id |
string |
auto | Widget için benzersiz tanımlayıcı |
widgetType |
'simple' | 'complex' | 'editable' |
'simple' |
Etkinleştirmenin nasıl davranacağını kontrol eden widget türü |
disabled |
boolean |
false |
Bu hücre widget'ını devre dışı bırakır |
focusTarget |
ElementResolver<HTMLElement> |
— | Etkinleştirmede odağı alacak isteğe bağlı öğe referansı |
tabindex |
number |
— | Widget için tabindex geçersiz kılması |
Çıktılar
| Property | Type | Description |
|---|---|---|
activated |
EventEmitter<KeyboardEvent | FocusEvent | undefined> |
Hücre widget'ı etkinleştiğinde yayılır |
deactivated |
EventEmitter<KeyboardEvent | FocusEvent | undefined> |
Hücre widget'ı devre dışı kaldığında yayılır |
Yöntemler
| Method | Parameters | Description |
|---|---|---|
activate |
none | Widget'ı zorla etkinleştirir |
deactivate |
none | Widget'ı zorla devre dışı bırakır |