- Add date_added to all 950+ skills for complete tracking - Update version to 6.5.0 in package.json and README - Regenerate all indexes and catalog - Sync all generated files Features from merged PR #150: - Stars/Upvotes system for community-driven discovery - Auto-update mechanism via START_APP.bat - Interactive Prompt Builder - Date tracking badges - Smart auto-categorization All skills validated and indexed. Made-with: Cursor
513 lines
11 KiB
Markdown
513 lines
11 KiB
Markdown
---
|
|
name: angular-ui-patterns
|
|
description: "Modern Angular UI patterns for loading states, error handling, and data display. Use when building UI components, handling async data, or managing component states."
|
|
risk: safe
|
|
source: self
|
|
date_added: "2026-02-27"
|
|
---
|
|
|
|
# Angular UI Patterns
|
|
|
|
## Core Principles
|
|
|
|
1. **Never show stale UI** - Loading states only when actually loading
|
|
2. **Always surface errors** - Users must know when something fails
|
|
3. **Optimistic updates** - Make the UI feel instant
|
|
4. **Progressive disclosure** - Use `@defer` to show content as available
|
|
5. **Graceful degradation** - Partial data is better than no data
|
|
|
|
---
|
|
|
|
## Loading State Patterns
|
|
|
|
### The Golden Rule
|
|
|
|
**Show loading indicator ONLY when there's no data to display.**
|
|
|
|
```typescript
|
|
@Component({
|
|
template: `
|
|
@if (error()) {
|
|
<app-error-state [error]="error()" (retry)="load()" />
|
|
} @else if (loading() && !items().length) {
|
|
<app-skeleton-list />
|
|
} @else if (!items().length) {
|
|
<app-empty-state message="No items found" />
|
|
} @else {
|
|
<app-item-list [items]="items()" />
|
|
}
|
|
`,
|
|
})
|
|
export class ItemListComponent {
|
|
private store = inject(ItemStore);
|
|
|
|
items = this.store.items;
|
|
loading = this.store.loading;
|
|
error = this.store.error;
|
|
}
|
|
```
|
|
|
|
### Loading State Decision Tree
|
|
|
|
```
|
|
Is there an error?
|
|
→ Yes: Show error state with retry option
|
|
→ No: Continue
|
|
|
|
Is it loading AND we have no data?
|
|
→ Yes: Show loading indicator (spinner/skeleton)
|
|
→ No: Continue
|
|
|
|
Do we have data?
|
|
→ Yes, with items: Show the data
|
|
→ Yes, but empty: Show empty state
|
|
→ No: Show loading (fallback)
|
|
```
|
|
|
|
### Skeleton vs Spinner
|
|
|
|
| Use Skeleton When | Use Spinner When |
|
|
| -------------------- | --------------------- |
|
|
| Known content shape | Unknown content shape |
|
|
| List/card layouts | Modal actions |
|
|
| Initial page load | Button submissions |
|
|
| Content placeholders | Inline operations |
|
|
|
|
---
|
|
|
|
## Control Flow Patterns
|
|
|
|
### @if/@else for Conditional Rendering
|
|
|
|
```html
|
|
@if (user(); as user) {
|
|
<span>Welcome, {{ user.name }}</span>
|
|
} @else if (loading()) {
|
|
<app-spinner size="small" />
|
|
} @else {
|
|
<a routerLink="/login">Sign In</a>
|
|
}
|
|
```
|
|
|
|
### @for with Track
|
|
|
|
```html
|
|
@for (item of items(); track item.id) {
|
|
<app-item-card [item]="item" (delete)="remove(item.id)" />
|
|
} @empty {
|
|
<app-empty-state
|
|
icon="inbox"
|
|
message="No items yet"
|
|
actionLabel="Create Item"
|
|
(action)="create()"
|
|
/>
|
|
}
|
|
```
|
|
|
|
### @defer for Progressive Loading
|
|
|
|
```html
|
|
<!-- Critical content loads immediately -->
|
|
<app-header />
|
|
<app-hero-section />
|
|
|
|
<!-- Non-critical content deferred -->
|
|
@defer (on viewport) {
|
|
<app-comments [postId]="postId()" />
|
|
} @placeholder {
|
|
<div class="h-32 bg-gray-100 animate-pulse"></div>
|
|
} @loading (minimum 200ms) {
|
|
<app-spinner />
|
|
} @error {
|
|
<app-error-state message="Failed to load comments" />
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Error Handling Patterns
|
|
|
|
### Error Handling Hierarchy
|
|
|
|
```
|
|
1. Inline error (field-level) → Form validation errors
|
|
2. Toast notification → Recoverable errors, user can retry
|
|
3. Error banner → Page-level errors, data still partially usable
|
|
4. Full error screen → Unrecoverable, needs user action
|
|
```
|
|
|
|
### Always Show Errors
|
|
|
|
**CRITICAL: Never swallow errors silently.**
|
|
|
|
```typescript
|
|
// CORRECT - Error always surfaced to user
|
|
@Component({...})
|
|
export class CreateItemComponent {
|
|
private store = inject(ItemStore);
|
|
private toast = inject(ToastService);
|
|
|
|
async create(data: CreateItemDto) {
|
|
try {
|
|
await this.store.create(data);
|
|
this.toast.success('Item created successfully');
|
|
this.router.navigate(['/items']);
|
|
} catch (error) {
|
|
console.error('createItem failed:', error);
|
|
this.toast.error('Failed to create item. Please try again.');
|
|
}
|
|
}
|
|
}
|
|
|
|
// WRONG - Error silently caught
|
|
async create(data: CreateItemDto) {
|
|
try {
|
|
await this.store.create(data);
|
|
} catch (error) {
|
|
console.error(error); // User sees nothing!
|
|
}
|
|
}
|
|
```
|
|
|
|
### Error State Component Pattern
|
|
|
|
```typescript
|
|
@Component({
|
|
selector: "app-error-state",
|
|
standalone: true,
|
|
imports: [NgOptimizedImage],
|
|
template: `
|
|
<div class="error-state">
|
|
<img ngSrc="/assets/error-icon.svg" width="64" height="64" alt="" />
|
|
<h3>{{ title() }}</h3>
|
|
<p>{{ message() }}</p>
|
|
@if (retry.observed) {
|
|
<button (click)="retry.emit()" class="btn-primary">Try Again</button>
|
|
}
|
|
</div>
|
|
`,
|
|
})
|
|
export class ErrorStateComponent {
|
|
title = input("Something went wrong");
|
|
message = input("An unexpected error occurred");
|
|
retry = output<void>();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Button State Patterns
|
|
|
|
### Button Loading State
|
|
|
|
```html
|
|
<button
|
|
(click)="handleSubmit()"
|
|
[disabled]="isSubmitting() || !form.valid"
|
|
class="btn-primary"
|
|
>
|
|
@if (isSubmitting()) {
|
|
<app-spinner size="small" class="mr-2" />
|
|
Saving... } @else { Save Changes }
|
|
</button>
|
|
```
|
|
|
|
### Disable During Operations
|
|
|
|
**CRITICAL: Always disable triggers during async operations.**
|
|
|
|
```typescript
|
|
// CORRECT - Button disabled while loading
|
|
@Component({
|
|
template: `
|
|
<button
|
|
[disabled]="saving()"
|
|
(click)="save()"
|
|
>
|
|
@if (saving()) {
|
|
<app-spinner size="sm" /> Saving...
|
|
} @else {
|
|
Save
|
|
}
|
|
</button>
|
|
`
|
|
})
|
|
export class SaveButtonComponent {
|
|
saving = signal(false);
|
|
|
|
async save() {
|
|
this.saving.set(true);
|
|
try {
|
|
await this.service.save();
|
|
} finally {
|
|
this.saving.set(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// WRONG - User can click multiple times
|
|
<button (click)="save()">
|
|
{{ saving() ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
```
|
|
|
|
---
|
|
|
|
## Empty States
|
|
|
|
### Empty State Requirements
|
|
|
|
Every list/collection MUST have an empty state:
|
|
|
|
```html
|
|
@for (item of items(); track item.id) {
|
|
<app-item-card [item]="item" />
|
|
} @empty {
|
|
<app-empty-state
|
|
icon="folder-open"
|
|
title="No items yet"
|
|
description="Create your first item to get started"
|
|
actionLabel="Create Item"
|
|
(action)="openCreateDialog()"
|
|
/>
|
|
}
|
|
```
|
|
|
|
### Contextual Empty States
|
|
|
|
```typescript
|
|
@Component({
|
|
selector: "app-empty-state",
|
|
template: `
|
|
<div class="empty-state">
|
|
<span class="icon" [class]="icon()"></span>
|
|
<h3>{{ title() }}</h3>
|
|
<p>{{ description() }}</p>
|
|
@if (actionLabel()) {
|
|
<button (click)="action.emit()" class="btn-primary">
|
|
{{ actionLabel() }}
|
|
</button>
|
|
}
|
|
</div>
|
|
`,
|
|
})
|
|
export class EmptyStateComponent {
|
|
icon = input("inbox");
|
|
title = input.required<string>();
|
|
description = input("");
|
|
actionLabel = input<string | null>(null);
|
|
action = output<void>();
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Form Patterns
|
|
|
|
### Form with Loading and Validation
|
|
|
|
```typescript
|
|
@Component({
|
|
template: `
|
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
|
<div class="form-field">
|
|
<label for="name">Name</label>
|
|
<input
|
|
id="name"
|
|
formControlName="name"
|
|
[class.error]="isFieldInvalid('name')"
|
|
/>
|
|
@if (isFieldInvalid("name")) {
|
|
<span class="error-text">
|
|
{{ getFieldError("name") }}
|
|
</span>
|
|
}
|
|
</div>
|
|
|
|
<div class="form-field">
|
|
<label for="email">Email</label>
|
|
<input id="email" type="email" formControlName="email" />
|
|
@if (isFieldInvalid("email")) {
|
|
<span class="error-text">
|
|
{{ getFieldError("email") }}
|
|
</span>
|
|
}
|
|
</div>
|
|
|
|
<button type="submit" [disabled]="form.invalid || submitting()">
|
|
@if (submitting()) {
|
|
<app-spinner size="sm" /> Submitting...
|
|
} @else {
|
|
Submit
|
|
}
|
|
</button>
|
|
</form>
|
|
`,
|
|
})
|
|
export class UserFormComponent {
|
|
private fb = inject(FormBuilder);
|
|
|
|
submitting = signal(false);
|
|
|
|
form = this.fb.group({
|
|
name: ["", [Validators.required, Validators.minLength(2)]],
|
|
email: ["", [Validators.required, Validators.email]],
|
|
});
|
|
|
|
isFieldInvalid(field: string): boolean {
|
|
const control = this.form.get(field);
|
|
return control ? control.invalid && control.touched : false;
|
|
}
|
|
|
|
getFieldError(field: string): string {
|
|
const control = this.form.get(field);
|
|
if (control?.hasError("required")) return "This field is required";
|
|
if (control?.hasError("email")) return "Invalid email format";
|
|
if (control?.hasError("minlength")) return "Too short";
|
|
return "";
|
|
}
|
|
|
|
async onSubmit() {
|
|
if (this.form.invalid) return;
|
|
|
|
this.submitting.set(true);
|
|
try {
|
|
await this.service.submit(this.form.value);
|
|
this.toast.success("Submitted successfully");
|
|
} catch {
|
|
this.toast.error("Submission failed");
|
|
} finally {
|
|
this.submitting.set(false);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Dialog/Modal Patterns
|
|
|
|
### Confirmation Dialog
|
|
|
|
```typescript
|
|
// dialog.service.ts
|
|
@Injectable({ providedIn: 'root' })
|
|
export class DialogService {
|
|
private dialog = inject(Dialog); // CDK Dialog or custom
|
|
|
|
async confirm(options: {
|
|
title: string;
|
|
message: string;
|
|
confirmText?: string;
|
|
cancelText?: string;
|
|
}): Promise<boolean> {
|
|
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
|
data: options,
|
|
});
|
|
|
|
return await firstValueFrom(dialogRef.closed) ?? false;
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
async deleteItem(item: Item) {
|
|
const confirmed = await this.dialog.confirm({
|
|
title: 'Delete Item',
|
|
message: `Are you sure you want to delete "${item.name}"?`,
|
|
confirmText: 'Delete',
|
|
});
|
|
|
|
if (confirmed) {
|
|
await this.store.delete(item.id);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Anti-Patterns
|
|
|
|
### Loading States
|
|
|
|
```typescript
|
|
// WRONG - Spinner when data exists (causes flash on refetch)
|
|
@if (loading()) {
|
|
<app-spinner />
|
|
}
|
|
|
|
// CORRECT - Only show loading without data
|
|
@if (loading() && !items().length) {
|
|
<app-spinner />
|
|
}
|
|
```
|
|
|
|
### Error Handling
|
|
|
|
```typescript
|
|
// WRONG - Error swallowed
|
|
try {
|
|
await this.service.save();
|
|
} catch (e) {
|
|
console.log(e); // User has no idea!
|
|
}
|
|
|
|
// CORRECT - Error surfaced
|
|
try {
|
|
await this.service.save();
|
|
} catch (e) {
|
|
console.error("Save failed:", e);
|
|
this.toast.error("Failed to save. Please try again.");
|
|
}
|
|
```
|
|
|
|
### Button States
|
|
|
|
```html
|
|
<!-- WRONG - Button not disabled during submission -->
|
|
<button (click)="submit()">Submit</button>
|
|
|
|
<!-- CORRECT - Disabled and shows loading -->
|
|
<button (click)="submit()" [disabled]="loading()">
|
|
@if (loading()) {
|
|
<app-spinner size="sm" />
|
|
} Submit
|
|
</button>
|
|
```
|
|
|
|
---
|
|
|
|
## UI State Checklist
|
|
|
|
Before completing any UI component:
|
|
|
|
### UI States
|
|
|
|
- [ ] Error state handled and shown to user
|
|
- [ ] Loading state shown only when no data exists
|
|
- [ ] Empty state provided for collections (`@empty` block)
|
|
- [ ] Buttons disabled during async operations
|
|
- [ ] Buttons show loading indicator when appropriate
|
|
|
|
### Data & Mutations
|
|
|
|
- [ ] All async operations have error handling
|
|
- [ ] All user actions have feedback (toast/visual)
|
|
- [ ] Optimistic updates rollback on failure
|
|
|
|
### Accessibility
|
|
|
|
- [ ] Loading states announced to screen readers
|
|
- [ ] Error messages linked to form fields
|
|
- [ ] Focus management after state changes
|
|
|
|
---
|
|
|
|
## Integration with Other Skills
|
|
|
|
- **angular-state-management**: Use Signal stores for state
|
|
- **angular**: Apply modern patterns (Signals, @defer)
|
|
- **testing-patterns**: Test all UI states
|
|
|
|
## When to Use
|
|
This skill is applicable to execute the workflow or actions described in the overview.
|