ui-ux-consultant-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/ui-ux-consultant/SKILL.md +844 -0
- package/assets/ui-ux-consultant/references/accessibility.md +175 -0
- package/assets/ui-ux-consultant/references/alt-libraries.md +90 -0
- package/assets/ui-ux-consultant/references/animations.md +448 -0
- package/assets/ui-ux-consultant/references/catalog/colors.md +91 -0
- package/assets/ui-ux-consultant/references/catalog/fonts.md +363 -0
- package/assets/ui-ux-consultant/references/catalog/products.md +340 -0
- package/assets/ui-ux-consultant/references/catalog/styles.md +165 -0
- package/assets/ui-ux-consultant/references/components.md +1116 -0
- package/assets/ui-ux-consultant/references/patterns.md +600 -0
- package/assets/ui-ux-consultant/references/performance.md +198 -0
- package/assets/ui-ux-consultant/references/stacks/astro.md +382 -0
- package/assets/ui-ux-consultant/references/stacks/flutter.md +308 -0
- package/assets/ui-ux-consultant/references/stacks/html-tailwind.md +415 -0
- package/assets/ui-ux-consultant/references/stacks/jetpack-compose.md +333 -0
- package/assets/ui-ux-consultant/references/stacks/laravel.md +521 -0
- package/assets/ui-ux-consultant/references/stacks/nextjs.md +275 -0
- package/assets/ui-ux-consultant/references/stacks/nuxt-ui.md +384 -0
- package/assets/ui-ux-consultant/references/stacks/nuxtjs.md +264 -0
- package/assets/ui-ux-consultant/references/stacks/react-native.md +346 -0
- package/assets/ui-ux-consultant/references/stacks/react.md +268 -0
- package/assets/ui-ux-consultant/references/stacks/shadcn.md +485 -0
- package/assets/ui-ux-consultant/references/stacks/svelte.md +429 -0
- package/assets/ui-ux-consultant/references/stacks/swiftui.md +336 -0
- package/assets/ui-ux-consultant/references/stacks/threejs.md +366 -0
- package/assets/ui-ux-consultant/references/stacks/vue.md +272 -0
- package/assets/ui-ux-consultant/references/theming.md +701 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +130 -0
- package/package.json +51 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
# Angular UI/UX Patterns Reference
|
|
2
|
+
|
|
3
|
+
Angular UI/UX patterns for signals, forms, routing, and component architecture.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Section 1: Signals-Based UI Patterns (Angular 17+)
|
|
8
|
+
|
|
9
|
+
### Pattern: Signal-Driven List with Loading/Empty/Error States
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'app-user-list',
|
|
14
|
+
standalone: true,
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
imports: [MatProgressBarModule, MatListModule, CommonModule],
|
|
17
|
+
template: `
|
|
18
|
+
@if (loading()) {
|
|
19
|
+
<mat-progress-bar mode="indeterminate" />
|
|
20
|
+
} @else if (error()) {
|
|
21
|
+
<div class="error">{{ error() }}</div>
|
|
22
|
+
} @else if (users().length === 0) {
|
|
23
|
+
<p class="empty">No users found.</p>
|
|
24
|
+
} @else {
|
|
25
|
+
<mat-list>
|
|
26
|
+
@for (user of users(); track user.id) {
|
|
27
|
+
<mat-list-item>{{ user.name }}</mat-list-item>
|
|
28
|
+
}
|
|
29
|
+
</mat-list>
|
|
30
|
+
}
|
|
31
|
+
`,
|
|
32
|
+
})
|
|
33
|
+
export class UserListComponent {
|
|
34
|
+
private userService = inject(UserService);
|
|
35
|
+
|
|
36
|
+
readonly loading = signal(false);
|
|
37
|
+
readonly error = signal<string | null>(null);
|
|
38
|
+
readonly users = signal<User[]>([]);
|
|
39
|
+
|
|
40
|
+
ngOnInit() { this.load(); }
|
|
41
|
+
|
|
42
|
+
async load() {
|
|
43
|
+
this.loading.set(true);
|
|
44
|
+
this.error.set(null);
|
|
45
|
+
try {
|
|
46
|
+
this.users.set(await this.userService.getAll());
|
|
47
|
+
} catch (e) {
|
|
48
|
+
this.error.set('Failed to load users. Try again.');
|
|
49
|
+
} finally {
|
|
50
|
+
this.loading.set(false);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Pattern: Optimistic UI with Rollback
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
async toggleFavorite(item: Item) {
|
|
60
|
+
const snapshot = this.items();
|
|
61
|
+
// Optimistic update
|
|
62
|
+
this.items.update(list =>
|
|
63
|
+
list.map(i => i.id === item.id ? { ...i, isFavorite: !i.isFavorite } : i)
|
|
64
|
+
);
|
|
65
|
+
try {
|
|
66
|
+
await this.api.toggleFavorite(item.id);
|
|
67
|
+
} catch {
|
|
68
|
+
this.items.set(snapshot); // rollback
|
|
69
|
+
this.snackBar.open('Update failed. Changes reverted.', 'OK', { duration: 5000 });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Pattern: Derived/Computed State
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
readonly searchQuery = signal('');
|
|
78
|
+
readonly allItems = signal<Item[]>([]);
|
|
79
|
+
readonly filteredItems = computed(() => {
|
|
80
|
+
const q = this.searchQuery().toLowerCase();
|
|
81
|
+
return q ? this.allItems().filter(i => i.name.toLowerCase().includes(q)) : this.allItems();
|
|
82
|
+
});
|
|
83
|
+
readonly totalCount = computed(() => this.filteredItems().length);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Pattern: RxJS to Signals Bridge
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
private destroy$ = inject(DestroyRef);
|
|
90
|
+
|
|
91
|
+
// Convert Observable to signal (use in constructor or ngOnInit)
|
|
92
|
+
readonly currentUser = toSignal(this.authService.currentUser$, { initialValue: null });
|
|
93
|
+
|
|
94
|
+
// Subscribe with auto-cleanup
|
|
95
|
+
this.someObservable$
|
|
96
|
+
.pipe(takeUntilDestroyed(this.destroy$))
|
|
97
|
+
.subscribe(value => this.mySignal.set(value));
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Section 2: Smart/Dumb Component Pattern
|
|
103
|
+
|
|
104
|
+
### Smart (Container) Component
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
@Component({
|
|
108
|
+
selector: 'app-dashboard-page',
|
|
109
|
+
standalone: true,
|
|
110
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
111
|
+
imports: [DashboardStatsComponent, RecentActivityComponent],
|
|
112
|
+
template: `
|
|
113
|
+
<app-dashboard-stats [stats]="stats()" (refresh)="loadStats()" />
|
|
114
|
+
<app-recent-activity [activities]="activities()" />
|
|
115
|
+
`,
|
|
116
|
+
})
|
|
117
|
+
export class DashboardPageComponent {
|
|
118
|
+
private dashboardService = inject(DashboardService);
|
|
119
|
+
readonly stats = toSignal(this.dashboardService.stats$);
|
|
120
|
+
readonly activities = toSignal(this.dashboardService.recentActivities$);
|
|
121
|
+
loadStats() { this.dashboardService.refresh(); }
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Dumb (Presentation) Component
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
@Component({
|
|
129
|
+
selector: 'app-dashboard-stats',
|
|
130
|
+
standalone: true,
|
|
131
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
132
|
+
// No injected services — pure input/output
|
|
133
|
+
template: `...`,
|
|
134
|
+
})
|
|
135
|
+
export class DashboardStatsComponent {
|
|
136
|
+
readonly stats = input<Stats | undefined>();
|
|
137
|
+
readonly refresh = output<void>();
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Rules:**
|
|
142
|
+
- Dumb components have zero injected services.
|
|
143
|
+
- All data comes via `input()`.
|
|
144
|
+
- All events go via `output()`.
|
|
145
|
+
- Easy to test, easy to reuse.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Section 3: Angular Router UX Patterns
|
|
150
|
+
|
|
151
|
+
### Lazy Loading with Loading UX
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
// app.routes.ts
|
|
155
|
+
export const routes: Routes = [
|
|
156
|
+
{
|
|
157
|
+
path: 'dashboard',
|
|
158
|
+
loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
path: 'settings',
|
|
162
|
+
loadChildren: () => import('./settings/settings.routes').then(m => m.SETTINGS_ROUTES),
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Route-Level Loading Indicator
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
// app.component.ts
|
|
171
|
+
readonly isNavigating = signal(false);
|
|
172
|
+
|
|
173
|
+
constructor() {
|
|
174
|
+
const router = inject(Router);
|
|
175
|
+
router.events.pipe(
|
|
176
|
+
takeUntilDestroyed(),
|
|
177
|
+
).subscribe(event => {
|
|
178
|
+
if (event instanceof NavigationStart) this.isNavigating.set(true);
|
|
179
|
+
if (event instanceof NavigationEnd || event instanceof NavigationCancel) this.isNavigating.set(false);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```html
|
|
185
|
+
<!-- app.component.html -->
|
|
186
|
+
@if (isNavigating()) {
|
|
187
|
+
<mat-progress-bar mode="indeterminate" class="nav-loader" />
|
|
188
|
+
}
|
|
189
|
+
<router-outlet />
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Data Prefetch with Resolvers
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// dashboard.resolver.ts
|
|
196
|
+
export const dashboardResolver: ResolveFn<DashboardData> = (route, state) => {
|
|
197
|
+
return inject(DashboardService).getData();
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// dashboard.routes.ts
|
|
201
|
+
{ path: 'dashboard', component: DashboardComponent, resolve: { data: dashboardResolver } }
|
|
202
|
+
|
|
203
|
+
// dashboard.component.ts
|
|
204
|
+
readonly data = toSignal(inject(ActivatedRoute).data.pipe(map(d => d['data'])));
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Functional Route Guards
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
export const authGuard: CanActivateFn = (route, state) => {
|
|
211
|
+
const auth = inject(AuthService);
|
|
212
|
+
const router = inject(Router);
|
|
213
|
+
if (auth.isLoggedIn()) return true;
|
|
214
|
+
return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
|
|
215
|
+
};
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Skeleton Loaders During Route Transition
|
|
219
|
+
|
|
220
|
+
Show skeleton UI immediately, replace with real content when data arrives:
|
|
221
|
+
|
|
222
|
+
```html
|
|
223
|
+
@if (data(); as d) {
|
|
224
|
+
<app-dashboard-content [data]="d" />
|
|
225
|
+
} @else {
|
|
226
|
+
<app-dashboard-skeleton /> <!-- CSS skeleton shimmer -->
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Section 4: Dialog & Sheet Patterns
|
|
233
|
+
|
|
234
|
+
### Opening a Dialog
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
private dialog = inject(MatDialog);
|
|
238
|
+
private destroyRef = inject(DestroyRef);
|
|
239
|
+
|
|
240
|
+
openEditDialog(item: Item) {
|
|
241
|
+
const ref = this.dialog.open(EditItemDialogComponent, {
|
|
242
|
+
width: '480px',
|
|
243
|
+
maxWidth: '95vw',
|
|
244
|
+
data: { item },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
ref.afterClosed().pipe(
|
|
248
|
+
filter(result => !!result),
|
|
249
|
+
takeUntilDestroyed(this.destroyRef),
|
|
250
|
+
).subscribe(updatedItem => this.updateItem(updatedItem));
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Dialog Component
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
@Component({
|
|
258
|
+
standalone: true,
|
|
259
|
+
imports: [MatDialogModule, MatButtonModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule],
|
|
260
|
+
template: `
|
|
261
|
+
<h2 mat-dialog-title>Edit Item</h2>
|
|
262
|
+
<mat-dialog-content>
|
|
263
|
+
<mat-form-field appearance="outline">
|
|
264
|
+
<mat-label>Name</mat-label>
|
|
265
|
+
<input matInput [formControl]="nameControl" />
|
|
266
|
+
<mat-error>Name is required</mat-error>
|
|
267
|
+
</mat-form-field>
|
|
268
|
+
</mat-dialog-content>
|
|
269
|
+
<mat-dialog-actions align="end">
|
|
270
|
+
<button mat-button mat-dialog-close>Cancel</button>
|
|
271
|
+
<button mat-flat-button color="primary" [disabled]="!nameControl.valid" (click)="save()">Save</button>
|
|
272
|
+
</mat-dialog-actions>
|
|
273
|
+
`,
|
|
274
|
+
})
|
|
275
|
+
export class EditItemDialogComponent {
|
|
276
|
+
private dialogRef = inject(MatDialogRef<EditItemDialogComponent>);
|
|
277
|
+
private data = inject<{ item: Item }>(MAT_DIALOG_DATA);
|
|
278
|
+
|
|
279
|
+
readonly nameControl = new FormControl(this.data.item.name, Validators.required);
|
|
280
|
+
|
|
281
|
+
save() {
|
|
282
|
+
if (this.nameControl.valid) {
|
|
283
|
+
this.dialogRef.close({ ...this.data.item, name: this.nameControl.value });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Bottom Sheet Pattern
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
private bottomSheet = inject(MatBottomSheet);
|
|
293
|
+
|
|
294
|
+
openOptions(item: Item) {
|
|
295
|
+
const ref = this.bottomSheet.open(ItemOptionsSheetComponent, { data: { item } });
|
|
296
|
+
ref.afterDismissed().pipe(
|
|
297
|
+
filter(action => !!action),
|
|
298
|
+
).subscribe(action => this.handleAction(action, item));
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Section 5: Reactive Forms Patterns
|
|
305
|
+
|
|
306
|
+
### Typed Form Group
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
interface UserForm {
|
|
310
|
+
name: FormControl<string>;
|
|
311
|
+
email: FormControl<string>;
|
|
312
|
+
role: FormControl<'admin' | 'user'>;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
readonly form = new FormGroup<UserForm>({
|
|
316
|
+
name: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
|
|
317
|
+
email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }),
|
|
318
|
+
role: new FormControl('user', { nonNullable: true }),
|
|
319
|
+
});
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Form Validation with Signal-Driven Error Display
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
// In component
|
|
326
|
+
readonly nameError = computed(() => {
|
|
327
|
+
const ctrl = this.form.controls.name;
|
|
328
|
+
if (ctrl.hasError('required')) return 'Name is required';
|
|
329
|
+
if (ctrl.hasError('minlength')) return 'Name must be at least 2 characters';
|
|
330
|
+
return null;
|
|
331
|
+
});
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
```html
|
|
335
|
+
<mat-form-field appearance="outline">
|
|
336
|
+
<mat-label>Name</mat-label>
|
|
337
|
+
<input matInput formControlName="name" />
|
|
338
|
+
@if (nameError()) {
|
|
339
|
+
<mat-error>{{ nameError() }}</mat-error>
|
|
340
|
+
}
|
|
341
|
+
</mat-form-field>
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Async Validator (e.g., Check Username Availability)
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
const usernameAvailable: AsyncValidatorFn = (ctrl) => {
|
|
348
|
+
return inject(UserService).checkUsername(ctrl.value).pipe(
|
|
349
|
+
map(available => available ? null : { usernameTaken: true }),
|
|
350
|
+
catchError(() => of(null)),
|
|
351
|
+
);
|
|
352
|
+
};
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Form Submit with Loading State
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
readonly saving = signal(false);
|
|
359
|
+
|
|
360
|
+
async submit() {
|
|
361
|
+
if (this.form.invalid || this.saving()) return;
|
|
362
|
+
this.saving.set(true);
|
|
363
|
+
try {
|
|
364
|
+
await this.service.save(this.form.getRawValue());
|
|
365
|
+
this.snackBar.open('Saved!', undefined, { duration: 3000 });
|
|
366
|
+
this.dialogRef.close(true);
|
|
367
|
+
} catch {
|
|
368
|
+
this.snackBar.open('Save failed. Please try again.', 'OK', { duration: 5000 });
|
|
369
|
+
} finally {
|
|
370
|
+
this.saving.set(false);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
```html
|
|
376
|
+
<button mat-flat-button color="primary" [disabled]="form.invalid || saving()" (click)="submit()">
|
|
377
|
+
@if (saving()) { <mat-spinner diameter="18" /> } @else { Save }
|
|
378
|
+
</button>
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## Section 6: Responsive Layout Pattern
|
|
384
|
+
|
|
385
|
+
### Adaptive Sidenav
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
readonly isHandset = toSignal(
|
|
389
|
+
inject(BreakpointObserver).observe(Breakpoints.Handset).pipe(map(r => r.matches)),
|
|
390
|
+
{ initialValue: false }
|
|
391
|
+
);
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
```html
|
|
395
|
+
<mat-sidenav-container>
|
|
396
|
+
<mat-sidenav
|
|
397
|
+
[mode]="isHandset() ? 'over' : 'side'"
|
|
398
|
+
[opened]="!isHandset()"
|
|
399
|
+
>
|
|
400
|
+
<mat-nav-list>...</mat-nav-list>
|
|
401
|
+
</mat-sidenav>
|
|
402
|
+
<mat-sidenav-content>
|
|
403
|
+
<router-outlet />
|
|
404
|
+
</mat-sidenav-content>
|
|
405
|
+
</mat-sidenav-container>
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### Responsive Grid with CSS Grid
|
|
409
|
+
|
|
410
|
+
```scss
|
|
411
|
+
.card-grid {
|
|
412
|
+
display: grid;
|
|
413
|
+
gap: 1rem;
|
|
414
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
415
|
+
|
|
416
|
+
@media (max-width: 600px) {
|
|
417
|
+
grid-template-columns: 1fr;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Breakpoint Constants (Angular CDK)
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
import { Breakpoints } from '@angular/cdk/layout';
|
|
426
|
+
// Breakpoints.Handset — phones portrait + landscape
|
|
427
|
+
// Breakpoints.Tablet — tablets portrait + landscape
|
|
428
|
+
// Breakpoints.Web — desktops
|
|
429
|
+
// Breakpoints.HandsetPortrait
|
|
430
|
+
// Breakpoints.TabletLandscape
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
---
|
|
434
|
+
|
|
435
|
+
## Section 7: Infinite Scroll with CDK Virtual Scroll
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
// Use CdkVirtualScrollViewport for large lists
|
|
439
|
+
import { ScrollingModule } from '@angular/cdk/scrolling';
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
```html
|
|
443
|
+
<cdk-virtual-scroll-viewport itemSize="64" class="list-viewport">
|
|
444
|
+
<mat-list-item *cdkVirtualFor="let item of items; trackBy: trackById">
|
|
445
|
+
{{ item.name }}
|
|
446
|
+
</mat-list-item>
|
|
447
|
+
</cdk-virtual-scroll-viewport>
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
```scss
|
|
451
|
+
.list-viewport { height: 400px; }
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
For paginated infinite scroll (load-more on scroll):
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
readonly page = signal(0);
|
|
458
|
+
readonly allItems = signal<Item[]>([]);
|
|
459
|
+
readonly hasMore = signal(true);
|
|
460
|
+
|
|
461
|
+
async loadMore() {
|
|
462
|
+
if (!this.hasMore()) return;
|
|
463
|
+
const next = await this.service.getPage(this.page());
|
|
464
|
+
this.allItems.update(prev => [...prev, ...next.items]);
|
|
465
|
+
this.hasMore.set(next.hasMore);
|
|
466
|
+
this.page.update(p => p + 1);
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## Section 8: Error Boundary & Global Error Handling
|
|
473
|
+
|
|
474
|
+
### Global HTTP Error Interceptor
|
|
475
|
+
|
|
476
|
+
```typescript
|
|
477
|
+
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
|
|
478
|
+
return next(req).pipe(
|
|
479
|
+
catchError((error: HttpErrorResponse) => {
|
|
480
|
+
const snackBar = inject(MatSnackBar);
|
|
481
|
+
if (error.status === 401) {
|
|
482
|
+
inject(Router).navigate(['/login']);
|
|
483
|
+
} else if (error.status >= 500) {
|
|
484
|
+
snackBar.open('Server error. Please try again later.', 'OK', { duration: 5000 });
|
|
485
|
+
}
|
|
486
|
+
return throwError(() => error);
|
|
487
|
+
}),
|
|
488
|
+
);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// app.config.ts
|
|
492
|
+
providers: [provideHttpClient(withInterceptors([errorInterceptor]))]
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
### Component-Level Error State
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
readonly error = signal<string | null>(null);
|
|
499
|
+
|
|
500
|
+
async load() {
|
|
501
|
+
this.error.set(null);
|
|
502
|
+
try {
|
|
503
|
+
this.data.set(await this.service.get());
|
|
504
|
+
} catch (e: unknown) {
|
|
505
|
+
if (e instanceof HttpErrorResponse && e.status === 404) {
|
|
506
|
+
this.error.set('Item not found.');
|
|
507
|
+
} else {
|
|
508
|
+
this.error.set('Something went wrong. Please try again.');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
## Section 9: Accessibility Patterns
|
|
517
|
+
|
|
518
|
+
### Focus Management After Dialog Close
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
// Store focused element before opening, restore after close
|
|
522
|
+
openDialog(triggerEl: HTMLElement) {
|
|
523
|
+
const ref = this.dialog.open(MyDialogComponent);
|
|
524
|
+
ref.afterClosed().subscribe(() => triggerEl.focus());
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
### ARIA Live Regions for Dynamic Content
|
|
529
|
+
|
|
530
|
+
```html
|
|
531
|
+
<!-- Announce async updates to screen readers -->
|
|
532
|
+
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
|
533
|
+
@if (statusMessage()) { {{ statusMessage() }} }
|
|
534
|
+
</div>
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
```scss
|
|
538
|
+
.sr-only {
|
|
539
|
+
position: absolute;
|
|
540
|
+
width: 1px;
|
|
541
|
+
height: 1px;
|
|
542
|
+
padding: 0;
|
|
543
|
+
overflow: hidden;
|
|
544
|
+
clip: rect(0, 0, 0, 0);
|
|
545
|
+
white-space: nowrap;
|
|
546
|
+
border: 0;
|
|
547
|
+
}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### Keyboard Navigation in Custom Components
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
@HostListener('keydown', ['$event'])
|
|
554
|
+
onKeydown(event: KeyboardEvent) {
|
|
555
|
+
switch (event.key) {
|
|
556
|
+
case 'ArrowDown': this.focusNext(); break;
|
|
557
|
+
case 'ArrowUp': this.focusPrev(); break;
|
|
558
|
+
case 'Enter':
|
|
559
|
+
case ' ': this.select(); event.preventDefault(); break;
|
|
560
|
+
case 'Escape': this.close(); break;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
---
|
|
566
|
+
|
|
567
|
+
## Section 10: Performance Patterns
|
|
568
|
+
|
|
569
|
+
### OnPush + Signals (Default for All New Components)
|
|
570
|
+
|
|
571
|
+
Always use `ChangeDetectionStrategy.OnPush` with signals. Angular's signal-based reactivity only triggers re-render when signal values change — no zone.js overhead.
|
|
572
|
+
|
|
573
|
+
```typescript
|
|
574
|
+
@Component({
|
|
575
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
576
|
+
// signals automatically schedule re-renders
|
|
577
|
+
})
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### trackBy in @for
|
|
581
|
+
|
|
582
|
+
Always provide `track` in `@for` to avoid full list re-renders:
|
|
583
|
+
|
|
584
|
+
```html
|
|
585
|
+
@for (item of items(); track item.id) {
|
|
586
|
+
<app-item [item]="item" />
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### Defer Block for Below-the-Fold Content
|
|
591
|
+
|
|
592
|
+
```html
|
|
593
|
+
@defer (on viewport) {
|
|
594
|
+
<app-heavy-chart [data]="chartData()" />
|
|
595
|
+
} @placeholder {
|
|
596
|
+
<div class="chart-placeholder">Loading chart...</div>
|
|
597
|
+
} @loading (minimum 500ms) {
|
|
598
|
+
<mat-spinner />
|
|
599
|
+
}
|
|
600
|
+
```
|