skeleton loading
This commit is contained in:
@@ -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">
|
||||
@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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? [])
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<div
|
||||
class="skeleton"
|
||||
aria-hidden="true"
|
||||
[style.width]="width()"
|
||||
[style.height]="height()"
|
||||
[style.border-radius]="radius()"
|
||||
></div>
|
||||
31
myteamwallet_frontend_modern/src/app/shared/skeleton/skeleton.scss
vendored
Normal file
31
myteamwallet_frontend_modern/src/app/shared/skeleton/skeleton.scss
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user