[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6EX92FPJQ0T0S6C382KX59A":3,"skill-same:01M6EX92FPJQ0T0S6C382KX59A":82},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":40,"linkOnly":41,"markdownContent":42,"markdownBody":43,"frontMatter":44,"refName":45,"commitSha":46,"skillDirectory":47,"forksCount":48,"repositoryHtmlUrl":49,"repositoryHomepageUrl":50,"ownerHtmlUrl":51,"ownerBlogUrl":6,"ownerDescription":52,"updatedAt":53,"install":54,"resources":67},"01M6EX92FPJQ0T0S6C382KX59A","angular-ui-patterns",null,"Modern Angular UI patterns for loading states, error handling, and data display. Use when building UI components, handling async data, or managing component states.","ComeOnOliver\u002Fskillshub","ComeOnOliver","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F43215937?v=4","01M6E5JNFECJAAVFE0W7SMWWD2","Haoran Shu","01M6E5JNNDQS7AJZDYFQ8TPZCK",67,"MIT","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Faiskillstore\u002Fmarketplace\u002Fsickn33\u002Fangular-ui-patterns\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000032","设计师","PROFESSION","适用职业",[24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39],"ui","angular","angular ui patterns","patterns","safe","components","component","error handling","data","use","async","self","ui components","error","states","loading","2026-11-10 10:12:30",false,"---\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\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","# 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",{"name":5,"risk":28,"source":35,"description":7},"main","def8531e65114c0fca8fb8551c1871ee0eed705c","skills\u002Faiskillstore\u002Fmarketplace\u002Fsickn33\u002Fangular-ui-patterns",24,"https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub","https:\u002F\u002Fskillshub.wtf","https:\u002F\u002Fgithub.com\u002FComeOnOliver","AI & Blockchain Engineer\r\n","2026-06-24 22:07:19",{"source":8,"skillName":5,"commands":55,"prompt":65,"zipAvailable":66},[56,59,62],{"tool":57,"command":58},"npx","npx skills add ComeOnOliver\u002Fskillshub --skill angular-ui-patterns",{"tool":60,"command":61},"bunx","bunx skills add ComeOnOliver\u002Fskillshub --skill angular-ui-patterns",{"tool":63,"command":64},"pnpm","pnpm dlx skills add ComeOnOliver\u002Fskillshub --skill angular-ui-patterns","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Faiskillstore\u002Fmarketplace\u002Fsickn33\u002Fangular-ui-patterns\u002FSKILL.md",true,[68,74,78],{"path":69,"resourceType":70,"resourceTypeDesc":71,"sizeBytes":72,"sourceHtmlUrl":73},"metadata.json","REFERENCE","参考文档",632,"https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Faiskillstore\u002Fmarketplace\u002Fsickn33\u002Fangular-ui-patterns\u002Fmetadata.json",{"path":75,"resourceType":70,"resourceTypeDesc":71,"sizeBytes":76,"sourceHtmlUrl":77},"README.md",1666,"https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Faiskillstore\u002Fmarketplace\u002Fsickn33\u002Fangular-ui-patterns\u002FREADME.md",{"path":79,"resourceType":70,"resourceTypeDesc":71,"sizeBytes":80,"sourceHtmlUrl":81},"skill-report.json",10138,"https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Faiskillstore\u002Fmarketplace\u002Fsickn33\u002Fangular-ui-patterns\u002Fskill-report.json",[83,97,107,116,122,129],{"skillNo":84,"name":85,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":86,"categories":87,"keywords":93,"publishedAt":40},"01M6FKJN13GMG4KY57J3QD8GY5","model-hierarchy-skill","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzscole\u002Fmodel-hierarchy-skill\u002Fmodel-hierarchy-skill\u002FSKILL.md",[88],{"categoryNo":89,"categoryName":90,"taxonomyType":91,"taxonomyTypeDesc":92},"01JBSEEDCAT000000000000023","Skill与智能体开发","SCENARIO","使用场景",[94,95,96],"model","model hierarchy","model hierarchy skill",{"skillNo":98,"name":99,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":100,"categories":101,"keywords":103,"publishedAt":40},"01M6FKJJJ6GCAMR4PPPRD12M5X","zoom-mcp","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzoom\u002Fskills\u002Fzoom-mcp\u002FSKILL.md",[102],{"categoryNo":89,"categoryName":90,"taxonomyType":91,"taxonomyTypeDesc":92},[104,105,106],"MCP","zoom","zoom mcp",{"skillNo":108,"name":109,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":110,"categories":111,"keywords":112,"publishedAt":40},"01M6FKJE3KYT3KPEZMEEHYKA6K","zoom-apps-sdk","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzoom\u002Fskills\u002Fzoom-apps-sdk\u002FSKILL.md",[],[113,114,115,105],"sdk","zoom apps sdk","apps",{"skillNo":117,"name":118,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":119,"categories":120,"keywords":121,"publishedAt":40},"01M6FKJ9EDH9Q5VWDF8HAJRSP0","windows","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzoom\u002Fskills\u002Fwindows\u002FSKILL.md",[],[118],{"skillNo":123,"name":124,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":125,"categories":126,"keywords":128,"publishedAt":40},"01M6FKJ666BEEFC3AE9GJ72Q93","whiteboard","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzoom\u002Fskills\u002Fwhiteboard\u002FSKILL.md",[127],{"categoryNo":89,"categoryName":90,"taxonomyType":91,"taxonomyTypeDesc":92},[124],{"skillNo":130,"name":131,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":132,"categories":133,"keywords":134,"publishedAt":40},"01M6FKJ21WFCDGTJQ2Q91T9DVW","websockets","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzoom\u002Fskills\u002Fwebsockets\u002FSKILL.md",[],[131]]