[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill-ff81437e-3f9b-4ce8-a022-e78ede9a67c3":3,"$ftIpicsOskjj2lg6QoXiTGZ8fj-FIgtvSajckwYRInI8":43},{"id":4,"title":5,"description":6,"categoryId":7,"moduleId":8,"tags":9,"prompt":10,"icon":11,"source":12,"sourceUrl":13,"authorId":14,"authorName":15,"isPublic":16,"stars":17,"runs":18,"createdAt":19,"updatedAt":19,"module":20,"category":27,"packages":34},"ff81437e-3f9b-4ce8-a022-e78ede9a67c3","angular-ui-patterns","现代Angular UI加载状态、错误处理和数据展示模式。用于构建UI组件、处理异步数据或管理组件状态时使用。","cat_coding_frontend","mod_coding","sickn33,coding","---\nname: angular-ui-patterns\ndescription: \"Modern Angular UI patterns for loading states, error handling, and data display. Use when building UI components, handling async data, or managing component states.\"\nrisk: safe\nsource: self\ndate_added: \"2026-02-27\"\n---\n\n# Angular UI Patterns\n\n## Core Principles\n\n1. **Never show stale UI** - Loading states only when actually loading\n2. **Always surface errors** - Users must know when something fails\n3. **Optimistic updates** - Make the UI feel instant\n4. **Progressive disclosure** - Use `@defer` to show content as available\n5. **Graceful degradation** - Partial data is better than no data\n\n---\n\n## Loading State Patterns\n\n### The Golden Rule\n\n**Show loading indicator ONLY when there's no data to display.**\n\n```typescript\n@Component({\n  template: `\n    @if (error()) {\n      \u003Capp-error-state [error]=\"error()\" (retry)=\"load()\" \u002F>\n    } @else if (loading() && !items().length) {\n      \u003Capp-skeleton-list \u002F>\n    } @else if (!items().length) {\n      \u003Capp-empty-state message=\"No items found\" \u002F>\n    } @else {\n      \u003Capp-item-list [items]=\"items()\" \u002F>\n    }\n  `,\n})\nexport class ItemListComponent {\n  private store = inject(ItemStore);\n\n  items = this.store.items;\n  loading = this.store.loading;\n  error = this.store.error;\n}\n```\n\n### Loading State Decision Tree\n\n```\nIs there an error?\n  → Yes: Show error state with retry option\n  → No: Continue\n\nIs it loading AND we have no data?\n  → Yes: Show loading indicator (spinner\u002Fskeleton)\n  → No: Continue\n\nDo we have data?\n  → Yes, with items: Show the data\n  → Yes, but empty: Show empty state\n  → No: Show loading (fallback)\n```\n\n### Skeleton vs Spinner\n\n| Use Skeleton When    | Use Spinner When      |\n| -------------------- | --------------------- |\n| Known content shape  | Unknown content shape |\n| List\u002Fcard layouts    | Modal actions         |\n| Initial page load    | Button submissions    |\n| Content placeholders | Inline operations     |\n\n---\n\n## Control Flow Patterns\n\n### @if\u002F@else for Conditional Rendering\n\n```html\n@if (user(); as user) {\n\u003Cspan>Welcome, {{ user.name }}\u003C\u002Fspan>\n} @else if (loading()) {\n\u003Capp-spinner size=\"small\" \u002F>\n} @else {\n\u003Ca routerLink=\"\u002Flogin\">Sign In\u003C\u002Fa>\n}\n```\n\n### @for with Track\n\n```html\n@for (item of items(); track item.id) {\n\u003Capp-item-card [item]=\"item\" (delete)=\"remove(item.id)\" \u002F>\n} @empty {\n\u003Capp-empty-state\n  icon=\"inbox\"\n  message=\"No items yet\"\n  actionLabel=\"Create Item\"\n  (action)=\"create()\"\n\u002F>\n}\n```\n\n### @defer for Progressive Loading\n\n```html\n\u003C!-- Critical content loads immediately -->\n\u003Capp-header \u002F>\n\u003Capp-hero-section \u002F>\n\n\u003C!-- Non-critical content deferred -->\n@defer (on viewport) {\n\u003Capp-comments [postId]=\"postId()\" \u002F>\n} @placeholder {\n\u003Cdiv class=\"h-32 bg-gray-100 animate-pulse\">\u003C\u002Fdiv>\n} @loading (minimum 200ms) {\n\u003Capp-spinner \u002F>\n} @error {\n\u003Capp-error-state message=\"Failed to load comments\" \u002F>\n}\n```\n\n---\n\n## Error Handling Patterns\n\n### Error Handling Hierarchy\n\n```\n1. Inline error (field-level) → Form validation errors\n2. Toast notification → Recoverable errors, user can retry\n3. Error banner → Page-level errors, data still partially usable\n4. Full error screen → Unrecoverable, needs user action\n```\n\n### Always Show Errors\n\n**CRITICAL: Never swallow errors silently.**\n\n```typescript\n\u002F\u002F CORRECT - Error always surfaced to user\n@Component({...})\nexport class CreateItemComponent {\n  private store = inject(ItemStore);\n  private toast = inject(ToastService);\n\n  async create(data: CreateItemDto) {\n    try {\n      await this.store.create(data);\n      this.toast.success('Item created successfully');\n      this.router.navigate(['\u002Fitems']);\n    } catch (error) {\n      console.error('createItem failed:', error);\n      this.toast.error('Failed to create item. Please try again.');\n    }\n  }\n}\n\n\u002F\u002F WRONG - Error silently caught\nasync create(data: CreateItemDto) {\n  try {\n    await this.store.create(data);\n  } catch (error) {\n    console.error(error); \u002F\u002F User sees nothing!\n  }\n}\n```\n\n### Error State Component Pattern\n\n```typescript\n@Component({\n  selector: \"app-error-state\",\n  standalone: true,\n  imports: [NgOptimizedImage],\n  template: `\n    \u003Cdiv class=\"error-state\">\n      \u003Cimg ngSrc=\"\u002Fassets\u002Ferror-icon.svg\" width=\"64\" height=\"64\" alt=\"\" \u002F>\n      \u003Ch3>{{ title() }}\u003C\u002Fh3>\n      \u003Cp>{{ message() }}\u003C\u002Fp>\n      @if (retry.observed) {\n        \u003Cbutton (click)=\"retry.emit()\" class=\"btn-primary\">Try Again\u003C\u002Fbutton>\n      }\n    \u003C\u002Fdiv>\n  `,\n})\nexport class ErrorStateComponent {\n  title = input(\"Something went wrong\");\n  message = input(\"An unexpected error occurred\");\n  retry = output\u003Cvoid>();\n}\n```\n\n---\n\n## Button State Patterns\n\n### Button Loading State\n\n```html\n\u003Cbutton\n  (click)=\"handleSubmit()\"\n  [disabled]=\"isSubmitting() || !form.valid\"\n  class=\"btn-primary\"\n>\n  @if (isSubmitting()) {\n  \u003Capp-spinner size=\"small\" class=\"mr-2\" \u002F>\n  Saving... } @else { Save Changes }\n\u003C\u002Fbutton>\n```\n\n### Disable During Operations\n\n**CRITICAL: Always disable triggers during async operations.**\n\n```typescript\n\u002F\u002F CORRECT - Button disabled while loading\n@Component({\n  template: `\n    \u003Cbutton\n      [disabled]=\"saving()\"\n      (click)=\"save()\"\n    >\n      @if (saving()) {\n        \u003Capp-spinner size=\"sm\" \u002F> Saving...\n      } @else {\n        Save\n      }\n    \u003C\u002Fbutton>\n  `\n})\nexport class SaveButtonComponent {\n  saving = signal(false);\n\n  async save() {\n    this.saving.set(true);\n    try {\n      await this.service.save();\n    } finally {\n      this.saving.set(false);\n    }\n  }\n}\n\n\u002F\u002F WRONG - User can click multiple times\n\u003Cbutton (click)=\"save()\">\n  {{ saving() ? 'Saving...' : 'Save' }}\n\u003C\u002Fbutton>\n```\n\n---\n\n## Empty States\n\n### Empty State Requirements\n\nEvery list\u002Fcollection MUST have an empty state:\n\n```html\n@for (item of items(); track item.id) {\n\u003Capp-item-card [item]=\"item\" \u002F>\n} @empty {\n\u003Capp-empty-state\n  icon=\"folder-open\"\n  title=\"No items yet\"\n  description=\"Create your first item to get started\"\n  actionLabel=\"Create Item\"\n  (action)=\"openCreateDialog()\"\n\u002F>\n}\n```\n\n### Contextual Empty States\n\n```typescript\n@Component({\n  selector: \"app-empty-state\",\n  template: `\n    \u003Cdiv class=\"empty-state\">\n      \u003Cspan class=\"icon\" [class]=\"icon()\">\u003C\u002Fspan>\n      \u003Ch3>{{ title() }}\u003C\u002Fh3>\n      \u003Cp>{{ description() }}\u003C\u002Fp>\n      @if (actionLabel()) {\n        \u003Cbutton (click)=\"action.emit()\" class=\"btn-primary\">\n          {{ actionLabel() }}\n        \u003C\u002Fbutton>\n      }\n    \u003C\u002Fdiv>\n  `,\n})\nexport class EmptyStateComponent {\n  icon = input(\"inbox\");\n  title = input.required\u003Cstring>();\n  description = input(\"\");\n  actionLabel = input\u003Cstring | null>(null);\n  action = output\u003Cvoid>();\n}\n```\n\n---\n\n## Form Patterns\n\n### Form with Loading and Validation\n\n```typescript\n@Component({\n  template: `\n    \u003Cform [formGroup]=\"form\" (ngSubmit)=\"onSubmit()\">\n      \u003Cdiv class=\"form-field\">\n        \u003Clabel for=\"name\">Name\u003C\u002Flabel>\n        \u003Cinput\n          id=\"name\"\n          formControlName=\"name\"\n          [class.error]=\"isFieldInvalid('name')\"\n        \u002F>\n        @if (isFieldInvalid(\"name\")) {\n          \u003Cspan class=\"error-text\">\n            {{ getFieldError(\"name\") }}\n          \u003C\u002Fspan>\n        }\n      \u003C\u002Fdiv>\n\n      \u003Cdiv class=\"form-field\">\n        \u003Clabel for=\"email\">Email\u003C\u002Flabel>\n        \u003Cinput id=\"email\" type=\"email\" formControlName=\"email\" \u002F>\n        @if (isFieldInvalid(\"email\")) {\n          \u003Cspan class=\"error-text\">\n            {{ getFieldError(\"email\") }}\n          \u003C\u002Fspan>\n        }\n      \u003C\u002Fdiv>\n\n      \u003Cbutton type=\"submit\" [disabled]=\"form.invalid || submitting()\">\n        @if (submitting()) {\n          \u003Capp-spinner size=\"sm\" \u002F> Submitting...\n        } @else {\n          Submit\n        }\n      \u003C\u002Fbutton>\n    \u003C\u002Fform>\n  `,\n})\nexport class UserFormComponent {\n  private fb = inject(FormBuilder);\n\n  submitting = signal(false);\n\n  form = this.fb.group({\n    name: [\"\", [Validators.required, Validators.minLength(2)]],\n    email: [\"\", [Validators.required, Validators.email]],\n  });\n\n  isFieldInvalid(field: string): boolean {\n    const control = this.form.get(field);\n    return control ? control.invalid && control.touched : false;\n  }\n\n  getFieldError(field: string): string {\n    const control = this.form.get(field);\n    if (control?.hasError(\"required\")) return \"This field is required\";\n    if (control?.hasError(\"email\")) return \"Invalid email format\";\n    if (control?.hasError(\"minlength\")) return \"Too short\";\n    return \"\";\n  }\n\n  async onSubmit() {\n    if (this.form.invalid) return;\n\n    this.submitting.set(true);\n    try {\n      await this.service.submit(this.form.value);\n      this.toast.success(\"Submitted successfully\");\n    } catch {\n      this.toast.error(\"Submission failed\");\n    } finally {\n      this.submitting.set(false);\n    }\n  }\n}\n```\n\n---\n\n## Dialog\u002FModal Patterns\n\n### Confirmation Dialog\n\n```typescript\n\u002F\u002F dialog.service.ts\n@Injectable({ providedIn: 'root' })\nexport class DialogService {\n  private dialog = inject(Dialog); \u002F\u002F CDK Dialog or custom\n\n  async confirm(options: {\n    title: string;\n    message: string;\n    confirmText?: string;\n    cancelText?: string;\n  }): Promise\u003Cboolean> {\n    const dialogRef = this.dialog.open(ConfirmDialogComponent, {\n      data: options,\n    });\n\n    return await firstValueFrom(dialogRef.closed) ?? false;\n  }\n}\n\n\u002F\u002F Usage\nasync deleteItem(item: Item) {\n  const confirmed = await this.dialog.confirm({\n    title: 'Delete Item',\n    message: `Are you sure you want to delete \"${item.name}\"?`,\n    confirmText: 'Delete',\n  });\n\n  if (confirmed) {\n    await this.store.delete(item.id);\n  }\n}\n```\n\n---\n\n## Anti-Patterns\n\n### Loading States\n\n```typescript\n\u002F\u002F WRONG - Spinner when data exists (causes flash on refetch)\n@if (loading()) {\n  \u003Capp-spinner \u002F>\n}\n\n\u002F\u002F CORRECT - Only show loading without data\n@if (loading() && !items().length) {\n  \u003Capp-spinner \u002F>\n}\n```\n\n### Error Handling\n\n```typescript\n\u002F\u002F WRONG - Error swallowed\ntry {\n  await this.service.save();\n} catch (e) {\n  console.log(e); \u002F\u002F User has no idea!\n}\n\n\u002F\u002F CORRECT - Error surfaced\ntry {\n  await this.service.save();\n} catch (e) {\n  console.error(\"Save failed:\", e);\n  this.toast.error(\"Failed to save. Please try again.\");\n}\n```\n\n### Button States\n\n```html\n\u003C!-- WRONG - Button not disabled during submission -->\n\u003Cbutton (click)=\"submit()\">Submit\u003C\u002Fbutton>\n\n\u003C!-- CORRECT - Disabled and shows loading -->\n\u003Cbutton (click)=\"submit()\" [disabled]=\"loading()\">\n  @if (loading()) {\n  \u003Capp-spinner size=\"sm\" \u002F>\n  } Submit\n\u003C\u002Fbutton>\n```\n\n---\n\n## UI State Checklist\n\nBefore completing any UI component:\n\n### UI States\n\n- [ ] Error state handled and shown to user\n- [ ] Loading state shown only when no data exists\n- [ ] Empty state provided for collections (`@empty` block)\n- [ ] Buttons disabled during async operations\n- [ ] Buttons show loading indicator when appropriate\n\n### Data & Mutations\n\n- [ ] All async operations have error handling\n- [ ] All user actions have feedback (toast\u002Fvisual)\n- [ ] Optimistic updates rollback on failure\n\n### Accessibility\n\n- [ ] Loading states announced to screen readers\n- [ ] Error messages linked to form fields\n- [ ] Focus management after state changes\n\n---\n\n## Integration with Other Skills\n\n- **angular-state-management**: Use Signal stores for state\n- **angular**: Apply modern patterns (Signals, @defer)\n- **testing-patterns**: Test all UI states\n\n## When to Use\nThis skill is applicable to execute the workflow or actions described in the overview.\n\n## Limitations\n- Use this skill only when the task clearly matches the scope described above.\n- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.\n- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.\n","","imported","https:\u002F\u002Fgithub.com\u002Fsickn33\u002Fantigravity-awesome-skills","user_system_seed","SkillOPIC",true,151,617,"2026-05-16 13:03:00",{"id":8,"name":21,"slug":22,"icon":23,"description":24,"sort":25,"createdAt":26},"编程开发","coding","mdi-code-braces","代码生成、调试、审查，提升开发效率",2,"2026-05-16 12:53:40",{"id":7,"name":28,"slug":29,"icon":30,"description":31,"moduleId":8,"sort":32,"skillCount":33,"createdAt":26},"前端开发","frontend","mdi-language-html5","HTML\u002FCSS\u002FJavaScript\u002F框架相关",1,96,[35],{"id":36,"skillId":4,"version":37,"fileName":38,"fileSize":39,"filePath":40,"fileHash":41,"manifest":42,"createdAt":19},"b477a81f-ae2c-4556-96de-ea9521a668fb","1.0.0","angular-ui-patterns.zip",5708,"uploads\u002Fskills\u002Fff81437e-3f9b-4ce8-a022-e78ede9a67c3\u002Fangular-ui-patterns.zip","b6a5a3e2089a856bec87e55254b9922e13e6464608ad8602a025c7cf93dc2ef7","[{\"path\":\"README.md\",\"isDirectory\":false,\"size\":1666},{\"path\":\"SKILL.md\",\"isDirectory\":false,\"size\":11610},{\"path\":\"metadata.json\",\"isDirectory\":false,\"size\":632}]",{"code":44,"message":45,"data":46},200,"success",{"items":47,"stats":48,"page":51},[],{"averageRating":49,"totalRatings":49,"ratingCounts":50},0,[49,49,49,49,49],{"limit":52,"offset":49,"hasMore":53,"nextOffset":52,"ratedOnly":16},15,false]