skeleton loading

This commit is contained in:
Bastian Wagner
2026-08-05 11:25:36 +02:00
parent 8405f797d7
commit bd421954c5
10 changed files with 146 additions and 9 deletions

View File

@@ -6,12 +6,22 @@
<main>
<a mat-button [routerLink]="['/t', token]"><mat-icon>arrow_back</mat-icon>Zur Teamübersicht</a>
<header class="page-title">
<p class="eyebrow">Öffentliche Ansicht</p>
<h1>{{ player()?.firstName }} {{ player()?.lastName }}</h1>
<p>Die letzten Buchungen dieses Mitglieds.</p>
@if (loading()) {
<app-skeleton width="100px" height="12px" />
<app-skeleton width="45%" height="40px" />
<app-skeleton width="70%" height="14px" />
} @else {
<p class="eyebrow">Öffentliche Ansicht</p>
<h1>{{ player()?.firstName }} {{ player()?.lastName }}</h1>
<p>Die letzten Buchungen dieses Mitglieds.</p>
}
</header>
@if (loading()) {
<div class="state"><mat-spinner diameter="40" /></div>
<div role="status" aria-label="Verlauf wird geladen" class="transactions">
@for (row of skeletonRows; track row) {
<app-skeleton height="60px" radius="17px" />
}
</div>
} @else if (notFound()) {
<div class="state"><mat-icon>search_off</mat-icon><span>Verlauf nicht gefunden.</span></div>
} @else if (transactions().length === 0) {

View File

@@ -37,6 +37,9 @@ main {
.page-title p {
margin-top: 0;
}
.page-title app-skeleton + app-skeleton {
margin-top: 10px;
}
.eyebrow {
color: var(--mat-sys-primary);
font-size: 0.75rem;

View File

@@ -5,10 +5,10 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { PublicTeamApi } from '../../core/team/public-team-api';
import { PlayerTransaction } from '../../models/transaction.model';
import { PublicPlayer as PublicPlayerModel } from '../../models/public-access.model';
import { Skeleton } from '../../shared/skeleton/skeleton';
import { TransactionAmount } from '../../shared/transaction-amount/transaction-amount';
registerLocaleData(localeDe);
@@ -21,7 +21,7 @@ registerLocaleData(localeDe);
MatButtonModule,
MatCardModule,
MatIconModule,
MatProgressSpinnerModule,
Skeleton,
TransactionAmount,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
@@ -36,6 +36,7 @@ export class PublicPlayer {
protected readonly transactions = signal<PlayerTransaction[]>([]);
protected readonly loading = signal(true);
protected readonly notFound = signal(false);
protected readonly skeletonRows = [0, 1, 2, 3, 4];
constructor() {
const playerId = Number(this.route.snapshot.paramMap.get('playerId'));

View File

@@ -6,7 +6,37 @@
</header>
<main>
@if (loading()) {
<div class="state"><mat-spinner diameter="42" /><span>Team wird geladen …</span></div>
<div role="status" aria-label="Team wird geladen">
<section class="hero">
<app-skeleton width="160px" height="12px" />
<app-skeleton width="45%" height="44px" />
<div class="balance-grid">
<app-skeleton height="94px" radius="20px" />
<app-skeleton height="94px" radius="20px" />
<app-skeleton height="94px" radius="20px" />
</div>
</section>
<section class="content-grid">
<div>
<app-skeleton width="90px" height="12px" />
<app-skeleton width="140px" height="26px" />
<div class="list-skeleton">
@for (row of skeletonMemberRows; track row) {
<app-skeleton height="54px" />
}
</div>
</div>
<aside>
<app-skeleton width="110px" height="12px" />
<app-skeleton width="160px" height="26px" />
<div class="list-skeleton">
@for (row of skeletonPenaltyRows; track row) {
<app-skeleton height="60px" radius="16px" />
}
</div>
</aside>
</section>
</div>
} @else if (notFound() || !team()) {
<div class="state">
<mat-icon>search_off</mat-icon>

View File

@@ -39,6 +39,13 @@ main {
text-transform: uppercase;
margin: 0 0 6px;
}
.hero app-skeleton + app-skeleton {
margin: 8px 0 20px;
}
.content-grid > div > app-skeleton,
.content-grid > aside > app-skeleton {
margin-bottom: 8px;
}
.balance-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -81,6 +88,10 @@ aside h2 {
.search {
width: 100%;
}
.list-skeleton {
display: grid;
gap: 10px;
}
.player-list {
overflow: hidden;
border: 1px solid var(--mat-sys-outline-variant);

View File

@@ -7,10 +7,10 @@ import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { PublicTeamApi } from '../../core/team/public-team-api';
import { Penalty } from '../../models/penalty.model';
import { PublicTeamOverview } from '../../models/public-access.model';
import { Skeleton } from '../../shared/skeleton/skeleton';
registerLocaleData(localeDe);
@@ -24,7 +24,7 @@ registerLocaleData(localeDe);
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
Skeleton,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './public-team.html',
@@ -38,6 +38,8 @@ export class PublicTeam {
protected readonly loading = signal(true);
protected readonly notFound = signal(false);
protected readonly search = signal('');
protected readonly skeletonMemberRows = [0, 1, 2, 3, 4];
protected readonly skeletonPenaltyRows = [0, 1, 2];
protected readonly players = computed(() => {
const query = this.search().trim().toLocaleLowerCase('de');
return (this.team()?.players ?? [])

View File

@@ -0,0 +1,7 @@
<div
class="skeleton"
aria-hidden="true"
[style.width]="width()"
[style.height]="height()"
[style.border-radius]="radius()"
></div>

View File

@@ -0,0 +1,31 @@
:host {
display: block;
}
.skeleton {
display: block;
background: linear-gradient(
100deg,
var(--mat-sys-surface-container) 30%,
var(--mat-sys-surface-container-high) 50%,
var(--mat-sys-surface-container) 70%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
background: var(--mat-sys-surface-container-high);
}
}

View File

@@ -0,0 +1,30 @@
import { TestBed } from '@angular/core/testing';
import { Skeleton } from './skeleton';
describe('Skeleton', () => {
it('renders a placeholder block sized from its inputs and hidden from screen readers', async () => {
await TestBed.configureTestingModule({ imports: [Skeleton] }).compileComponents();
const fixture = TestBed.createComponent(Skeleton);
fixture.componentRef.setInput('width', '60%');
fixture.componentRef.setInput('height', '24px');
fixture.componentRef.setInput('radius', '12px');
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('.skeleton');
expect(element.getAttribute('aria-hidden')).toBe('true');
expect(element.style.width).toBe('60%');
expect(element.style.height).toBe('24px');
expect(element.style.borderRadius).toBe('12px');
});
it('falls back to sensible default dimensions', async () => {
await TestBed.configureTestingModule({ imports: [Skeleton] }).compileComponents();
const fixture = TestBed.createComponent(Skeleton);
fixture.detectChanges();
const element = fixture.nativeElement.querySelector('.skeleton');
expect(element.style.width).toBe('100%');
expect(element.style.height).toBe('16px');
expect(element.style.borderRadius).toBe('8px');
});
});

View File

@@ -0,0 +1,12 @@
import { Component, input } from '@angular/core';
@Component({
selector: 'app-skeleton',
templateUrl: './skeleton.html',
styleUrl: './skeleton.scss',
})
export class Skeleton {
readonly width = input('100%');
readonly height = input('16px');
readonly radius = input('8px');
}