skeleton loading
This commit is contained in:
@@ -6,12 +6,22 @@
|
|||||||
<main>
|
<main>
|
||||||
<a mat-button [routerLink]="['/t', token]"><mat-icon>arrow_back</mat-icon>Zur Teamübersicht</a>
|
<a mat-button [routerLink]="['/t', token]"><mat-icon>arrow_back</mat-icon>Zur Teamübersicht</a>
|
||||||
<header class="page-title">
|
<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>
|
<p class="eyebrow">Öffentliche Ansicht</p>
|
||||||
<h1>{{ player()?.firstName }} {{ player()?.lastName }}</h1>
|
<h1>{{ player()?.firstName }} {{ player()?.lastName }}</h1>
|
||||||
<p>Die letzten Buchungen dieses Mitglieds.</p>
|
<p>Die letzten Buchungen dieses Mitglieds.</p>
|
||||||
|
}
|
||||||
</header>
|
</header>
|
||||||
@if (loading()) {
|
@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()) {
|
} @else if (notFound()) {
|
||||||
<div class="state"><mat-icon>search_off</mat-icon><span>Verlauf nicht gefunden.</span></div>
|
<div class="state"><mat-icon>search_off</mat-icon><span>Verlauf nicht gefunden.</span></div>
|
||||||
} @else if (transactions().length === 0) {
|
} @else if (transactions().length === 0) {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ main {
|
|||||||
.page-title p {
|
.page-title p {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
.page-title app-skeleton + app-skeleton {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
color: var(--mat-sys-primary);
|
color: var(--mat-sys-primary);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
||||||
import { PublicTeamApi } from '../../core/team/public-team-api';
|
import { PublicTeamApi } from '../../core/team/public-team-api';
|
||||||
import { PlayerTransaction } from '../../models/transaction.model';
|
import { PlayerTransaction } from '../../models/transaction.model';
|
||||||
import { PublicPlayer as PublicPlayerModel } from '../../models/public-access.model';
|
import { PublicPlayer as PublicPlayerModel } from '../../models/public-access.model';
|
||||||
|
import { Skeleton } from '../../shared/skeleton/skeleton';
|
||||||
import { TransactionAmount } from '../../shared/transaction-amount/transaction-amount';
|
import { TransactionAmount } from '../../shared/transaction-amount/transaction-amount';
|
||||||
|
|
||||||
registerLocaleData(localeDe);
|
registerLocaleData(localeDe);
|
||||||
@@ -21,7 +21,7 @@ registerLocaleData(localeDe);
|
|||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatProgressSpinnerModule,
|
Skeleton,
|
||||||
TransactionAmount,
|
TransactionAmount,
|
||||||
],
|
],
|
||||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||||
@@ -36,6 +36,7 @@ export class PublicPlayer {
|
|||||||
protected readonly transactions = signal<PlayerTransaction[]>([]);
|
protected readonly transactions = signal<PlayerTransaction[]>([]);
|
||||||
protected readonly loading = signal(true);
|
protected readonly loading = signal(true);
|
||||||
protected readonly notFound = signal(false);
|
protected readonly notFound = signal(false);
|
||||||
|
protected readonly skeletonRows = [0, 1, 2, 3, 4];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const playerId = Number(this.route.snapshot.paramMap.get('playerId'));
|
const playerId = Number(this.route.snapshot.paramMap.get('playerId'));
|
||||||
|
|||||||
@@ -6,7 +6,37 @@
|
|||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
@if (loading()) {
|
@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()) {
|
} @else if (notFound() || !team()) {
|
||||||
<div class="state">
|
<div class="state">
|
||||||
<mat-icon>search_off</mat-icon>
|
<mat-icon>search_off</mat-icon>
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ main {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 0 0 6px;
|
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 {
|
.balance-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
@@ -81,6 +88,10 @@ aside h2 {
|
|||||||
.search {
|
.search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.list-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
.player-list {
|
.player-list {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--mat-sys-outline-variant);
|
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 { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
|
||||||
import { PublicTeamApi } from '../../core/team/public-team-api';
|
import { PublicTeamApi } from '../../core/team/public-team-api';
|
||||||
import { Penalty } from '../../models/penalty.model';
|
import { Penalty } from '../../models/penalty.model';
|
||||||
import { PublicTeamOverview } from '../../models/public-access.model';
|
import { PublicTeamOverview } from '../../models/public-access.model';
|
||||||
|
import { Skeleton } from '../../shared/skeleton/skeleton';
|
||||||
|
|
||||||
registerLocaleData(localeDe);
|
registerLocaleData(localeDe);
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ registerLocaleData(localeDe);
|
|||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatProgressSpinnerModule,
|
Skeleton,
|
||||||
],
|
],
|
||||||
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
|
||||||
templateUrl: './public-team.html',
|
templateUrl: './public-team.html',
|
||||||
@@ -38,6 +38,8 @@ export class PublicTeam {
|
|||||||
protected readonly loading = signal(true);
|
protected readonly loading = signal(true);
|
||||||
protected readonly notFound = signal(false);
|
protected readonly notFound = signal(false);
|
||||||
protected readonly search = signal('');
|
protected readonly search = signal('');
|
||||||
|
protected readonly skeletonMemberRows = [0, 1, 2, 3, 4];
|
||||||
|
protected readonly skeletonPenaltyRows = [0, 1, 2];
|
||||||
protected readonly players = computed(() => {
|
protected readonly players = computed(() => {
|
||||||
const query = this.search().trim().toLocaleLowerCase('de');
|
const query = this.search().trim().toLocaleLowerCase('de');
|
||||||
return (this.team()?.players ?? [])
|
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