feat: Implement tasks feature using NGRX signals and remove the old counter store, alongside general project configuration and skill documentation updates.
continuous-integration/drone/pr Build is passing
continuous-integration/drone/pr Build is passing
This commit is contained in:
@@ -10,58 +10,54 @@ Create standalone components for Angular v20+. Components are standalone by defa
|
||||
## Component Structure
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
output,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
import { Component, ChangeDetectionStrategy, input, output, computed } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: "app-user-card",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: "user-card",
|
||||
"[class.active]": "isActive()",
|
||||
"(click)": "handleClick()",
|
||||
},
|
||||
template: `
|
||||
<img [src]="avatarUrl()" [alt]="name() + ' avatar'" />
|
||||
<h2>{{ name() }}</h2>
|
||||
@if (showEmail()) {
|
||||
<p>{{ email() }}</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
:host.active {
|
||||
border: 2px solid blue;
|
||||
}
|
||||
`,
|
||||
selector: 'app-user-card',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'class': 'user-card',
|
||||
'[class.active]': 'isActive()',
|
||||
'(click)': 'handleClick()',
|
||||
},
|
||||
template: `
|
||||
<img
|
||||
[src]="avatarUrl()"
|
||||
[alt]="name() + ' avatar'" />
|
||||
<h2>{{ name() }}</h2>
|
||||
@if (showEmail()) {
|
||||
<p>{{ email() }}</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
:host.active {
|
||||
border: 2px solid blue;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class UserCard {
|
||||
// Required input
|
||||
name = input.required<string>();
|
||||
// Required input
|
||||
name = input.required<string>();
|
||||
|
||||
// Optional input with default
|
||||
email = input<string>("");
|
||||
showEmail = input(false);
|
||||
// Optional input with default
|
||||
email = input<string>('');
|
||||
showEmail = input(false);
|
||||
|
||||
// Input with transform
|
||||
isActive = input(false, { transform: booleanAttribute });
|
||||
// Input with transform
|
||||
isActive = input(false, { transform: booleanAttribute });
|
||||
|
||||
// Computed from inputs
|
||||
avatarUrl = computed(() => `https://api.example.com/avatar/${this.name()}`);
|
||||
// Computed from inputs
|
||||
avatarUrl = computed(() => `https://api.example.com/avatar/${this.name()}`);
|
||||
|
||||
// Output
|
||||
selected = output<string>();
|
||||
// Output
|
||||
selected = output<string>();
|
||||
|
||||
handleClick() {
|
||||
this.selected.emit(this.name());
|
||||
}
|
||||
handleClick() {
|
||||
this.selected.emit(this.name());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -78,7 +74,7 @@ count = input(0);
|
||||
label = input<string>();
|
||||
|
||||
// With alias for template binding
|
||||
size = input("medium", { alias: "buttonSize" });
|
||||
size = input('medium', { alias: 'buttonSize' });
|
||||
|
||||
// With transform function
|
||||
disabled = input(false, { transform: booleanAttribute });
|
||||
@@ -88,14 +84,14 @@ value = input(0, { transform: numberAttribute });
|
||||
## Signal Outputs
|
||||
|
||||
```typescript
|
||||
import { output, outputFromObservable } from "@angular/core";
|
||||
import { output, outputFromObservable } from '@angular/core';
|
||||
|
||||
// Basic output
|
||||
clicked = output<void>();
|
||||
selected = output<Item>();
|
||||
|
||||
// With alias
|
||||
valueChange = output<number>({ alias: "change" });
|
||||
valueChange = output<number>({ alias: 'change' });
|
||||
|
||||
// From Observable (for RxJS interop)
|
||||
scroll$ = new Subject<number>();
|
||||
@@ -112,41 +108,41 @@ Use the `host` object in `@Component`—do NOT use `@HostBinding` or `@HostListe
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
selector: "app-button",
|
||||
host: {
|
||||
// Static attributes
|
||||
role: "button",
|
||||
selector: 'app-button',
|
||||
host: {
|
||||
// Static attributes
|
||||
'role': 'button',
|
||||
|
||||
// Dynamic class bindings
|
||||
"[class.primary]": 'variant() === "primary"',
|
||||
"[class.disabled]": "disabled()",
|
||||
// Dynamic class bindings
|
||||
'[class.primary]': 'variant() === "primary"',
|
||||
'[class.disabled]': 'disabled()',
|
||||
|
||||
// Dynamic style bindings
|
||||
"[style.--btn-color]": "color()",
|
||||
// Dynamic style bindings
|
||||
'[style.--btn-color]': 'color()',
|
||||
|
||||
// Attribute bindings
|
||||
"[attr.aria-disabled]": "disabled()",
|
||||
"[attr.tabindex]": "disabled() ? -1 : 0",
|
||||
// Attribute bindings
|
||||
'[attr.aria-disabled]': 'disabled()',
|
||||
'[attr.tabindex]': 'disabled() ? -1 : 0',
|
||||
|
||||
// Event listeners
|
||||
"(click)": "onClick($event)",
|
||||
"(keydown.enter)": "onClick($event)",
|
||||
"(keydown.space)": "onClick($event)",
|
||||
},
|
||||
template: `<ng-content />`,
|
||||
// Event listeners
|
||||
'(click)': 'onClick($event)',
|
||||
'(keydown.enter)': 'onClick($event)',
|
||||
'(keydown.space)': 'onClick($event)',
|
||||
},
|
||||
template: `<ng-content />`,
|
||||
})
|
||||
export class Button {
|
||||
variant = input<"primary" | "secondary">("primary");
|
||||
disabled = input(false, { transform: booleanAttribute });
|
||||
color = input("#007bff");
|
||||
variant = input<'primary' | 'secondary'>('primary');
|
||||
disabled = input(false, { transform: booleanAttribute });
|
||||
color = input('#007bff');
|
||||
|
||||
clicked = output<void>();
|
||||
clicked = output<void>();
|
||||
|
||||
onClick(event: Event) {
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
onClick(event: Event) {
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -154,18 +150,18 @@ export class Button {
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
selector: "app-card",
|
||||
template: `
|
||||
<header>
|
||||
<ng-content select="[card-header]" />
|
||||
</header>
|
||||
<main>
|
||||
<ng-content />
|
||||
</main>
|
||||
<footer>
|
||||
<ng-content select="[card-footer]" />
|
||||
</footer>
|
||||
`,
|
||||
selector: 'app-card',
|
||||
template: `
|
||||
<header>
|
||||
<ng-content select="[card-header]" />
|
||||
</header>
|
||||
<main>
|
||||
<ng-content />
|
||||
</main>
|
||||
<footer>
|
||||
<ng-content select="[card-footer]" />
|
||||
</footer>
|
||||
`,
|
||||
})
|
||||
export class Card {}
|
||||
|
||||
@@ -180,26 +176,26 @@ export class Card {}
|
||||
## Lifecycle Hooks
|
||||
|
||||
```typescript
|
||||
import { OnDestroy, OnInit, afterNextRender, afterRender } from "@angular/core";
|
||||
import { OnDestroy, OnInit, afterNextRender, afterRender } from '@angular/core';
|
||||
|
||||
export class My implements OnInit, OnDestroy {
|
||||
constructor() {
|
||||
// For DOM manipulation after render (SSR-safe)
|
||||
afterNextRender(() => {
|
||||
// Runs once after first render
|
||||
});
|
||||
constructor() {
|
||||
// For DOM manipulation after render (SSR-safe)
|
||||
afterNextRender(() => {
|
||||
// Runs once after first render
|
||||
});
|
||||
|
||||
afterRender(() => {
|
||||
// Runs after every render
|
||||
});
|
||||
}
|
||||
afterRender(() => {
|
||||
// Runs after every render
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
/* Component initialized */
|
||||
}
|
||||
ngOnDestroy() {
|
||||
/* Cleanup */
|
||||
}
|
||||
ngOnInit() {
|
||||
/* Component initialized */
|
||||
}
|
||||
ngOnDestroy() {
|
||||
/* Cleanup */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -215,28 +211,26 @@ Components MUST:
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
selector: "app-toggle",
|
||||
host: {
|
||||
role: "switch",
|
||||
"[attr.aria-checked]": "checked()",
|
||||
"[attr.aria-label]": "label()",
|
||||
tabindex: "0",
|
||||
"(click)": "toggle()",
|
||||
"(keydown.enter)": "toggle()",
|
||||
"(keydown.space)": "toggle(); $event.preventDefault()",
|
||||
},
|
||||
template: `<span class="toggle-track"
|
||||
><span class="toggle-thumb"></span
|
||||
></span>`,
|
||||
selector: 'app-toggle',
|
||||
host: {
|
||||
'role': 'switch',
|
||||
'[attr.aria-checked]': 'checked()',
|
||||
'[attr.aria-label]': 'label()',
|
||||
'tabindex': '0',
|
||||
'(click)': 'toggle()',
|
||||
'(keydown.enter)': 'toggle()',
|
||||
'(keydown.space)': 'toggle(); $event.preventDefault()',
|
||||
},
|
||||
template: `<span class="toggle-track"><span class="toggle-thumb"></span></span>`,
|
||||
})
|
||||
export class Toggle {
|
||||
label = input.required<string>();
|
||||
checked = input(false, { transform: booleanAttribute });
|
||||
checkedChange = output<boolean>();
|
||||
label = input.required<string>();
|
||||
checked = input(false, { transform: booleanAttribute });
|
||||
checkedChange = output<boolean>();
|
||||
|
||||
toggle() {
|
||||
this.checkedChange.emit(!this.checked());
|
||||
}
|
||||
toggle() {
|
||||
this.checkedChange.emit(!this.checked());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -262,8 +256,7 @@ Use native control flow—do NOT use `*ngIf`, `*ngFor`, `*ngSwitch`.
|
||||
}
|
||||
|
||||
<!-- Switch -->
|
||||
@switch (status()) { @case ('pending') { <span>Pending</span> } @case ('active')
|
||||
{ <span>Active</span> } @default { <span>Unknown</span> } }
|
||||
@switch (status()) { @case ('pending') { <span>Pending</span> } @case ('active') { <span>Active</span> } @default { <span>Unknown</span> } }
|
||||
```
|
||||
|
||||
## Class and Style Bindings
|
||||
@@ -285,17 +278,24 @@ Do NOT use `ngClass` or `ngStyle`. Use direct bindings:
|
||||
Use `NgOptimizedImage` for static images:
|
||||
|
||||
```typescript
|
||||
import { NgOptimizedImage } from "@angular/common";
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
imports: [NgOptimizedImage],
|
||||
template: `
|
||||
<img ngSrc="/assets/hero.jpg" width="800" height="600" priority />
|
||||
<img [ngSrc]="imageUrl()" width="200" height="200" />
|
||||
`,
|
||||
imports: [NgOptimizedImage],
|
||||
template: `
|
||||
<img
|
||||
ngSrc="/assets/hero.jpg"
|
||||
width="800"
|
||||
height="600"
|
||||
priority />
|
||||
<img
|
||||
width="200"
|
||||
height="200"
|
||||
[ngSrc]="imageUrl()" />
|
||||
`,
|
||||
})
|
||||
export class Hero {
|
||||
imageUrl = input.required<string>();
|
||||
imageUrl = input.required<string>();
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -14,28 +14,32 @@
|
||||
For two-way binding with `[(value)]` syntax:
|
||||
|
||||
```typescript
|
||||
import { Component, model } from "@angular/core";
|
||||
import { Component, model } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: "app-slider",
|
||||
host: {
|
||||
"(input)": "onInput($event)",
|
||||
},
|
||||
template: `
|
||||
<input type="range" [value]="value()" [min]="min()" [max]="max()" />
|
||||
<span>{{ value() }}</span>
|
||||
`,
|
||||
selector: 'app-slider',
|
||||
host: {
|
||||
'(input)': 'onInput($event)',
|
||||
},
|
||||
template: `
|
||||
<input
|
||||
type="range"
|
||||
[value]="value()"
|
||||
[min]="min()"
|
||||
[max]="max()" />
|
||||
<span>{{ value() }}</span>
|
||||
`,
|
||||
})
|
||||
export class Slider {
|
||||
// Model creates both input and output
|
||||
value = model(0);
|
||||
min = input(0);
|
||||
max = input(100);
|
||||
// Model creates both input and output
|
||||
value = model(0);
|
||||
min = input(0);
|
||||
max = input(100);
|
||||
|
||||
onInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
this.value.set(Number(target.value));
|
||||
}
|
||||
onInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
this.value.set(Number(target.value));
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: <app-slider [(value)]="sliderValue" />
|
||||
@@ -52,29 +56,31 @@ value = model.required<number>();
|
||||
Query elements and components in the template:
|
||||
|
||||
```typescript
|
||||
import { Component, viewChild, viewChildren, ElementRef } from "@angular/core";
|
||||
import { Component, viewChild, viewChildren, ElementRef } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: "app-gallery",
|
||||
template: `
|
||||
<div #container class="gallery">
|
||||
@for (image of images(); track image.id) {
|
||||
<app-image-card [image]="image" />
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
selector: 'app-gallery',
|
||||
template: `
|
||||
<div
|
||||
#container
|
||||
class="gallery">
|
||||
@for (image of images(); track image.id) {
|
||||
<app-image-card [image]="image" />
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class Gallery {
|
||||
images = input.required<Image[]>();
|
||||
images = input.required<Image[]>();
|
||||
|
||||
// Query single element
|
||||
container = viewChild.required<ElementRef<HTMLDivElement>>("container");
|
||||
// Query single element
|
||||
container = viewChild.required<ElementRef<HTMLDivElement>>('container');
|
||||
|
||||
// Query single component (optional)
|
||||
firstCard = viewChild(ImageCard);
|
||||
// Query single component (optional)
|
||||
firstCard = viewChild(ImageCard);
|
||||
|
||||
// Query all matching components
|
||||
allCards = viewChildren(ImageCard);
|
||||
// Query all matching components
|
||||
allCards = viewChildren(ImageCard);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -83,64 +89,60 @@ export class Gallery {
|
||||
Query projected content:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Component,
|
||||
contentChild,
|
||||
contentChildren,
|
||||
effect,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { Component, contentChild, contentChildren, effect, signal } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: "app-tabs",
|
||||
template: `
|
||||
<div class="tab-headers">
|
||||
@for (tab of tabs(); track tab.label()) {
|
||||
<button [class.active]="tab === activeTab()" (click)="selectTab(tab)">
|
||||
{{ tab.label() }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
selector: 'app-tabs',
|
||||
template: `
|
||||
<div class="tab-headers">
|
||||
@for (tab of tabs(); track tab.label()) {
|
||||
<button
|
||||
[class.active]="tab === activeTab()"
|
||||
(click)="selectTab(tab)">
|
||||
{{ tab.label() }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class Tabs {
|
||||
// Query all projected Tab children
|
||||
tabs = contentChildren(Tab);
|
||||
// Query all projected Tab children
|
||||
tabs = contentChildren(Tab);
|
||||
|
||||
// Query single projected element
|
||||
header = contentChild("tabHeader");
|
||||
// Query single projected element
|
||||
header = contentChild('tabHeader');
|
||||
|
||||
activeTab = signal<Tab | undefined>(undefined);
|
||||
activeTab = signal<Tab | undefined>(undefined);
|
||||
|
||||
constructor() {
|
||||
// Set first tab as active when tabs are available
|
||||
effect(() => {
|
||||
const firstTab = this.tabs()[0];
|
||||
if (firstTab && !this.activeTab()) {
|
||||
this.activeTab.set(firstTab);
|
||||
}
|
||||
});
|
||||
}
|
||||
constructor() {
|
||||
// Set first tab as active when tabs are available
|
||||
effect(() => {
|
||||
const firstTab = this.tabs()[0];
|
||||
if (firstTab && !this.activeTab()) {
|
||||
this.activeTab.set(firstTab);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectTab(tab: Tab) {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
selectTab(tab: Tab) {
|
||||
this.activeTab.set(tab);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-tab",
|
||||
template: `<ng-content />`,
|
||||
host: {
|
||||
"[class.active]": "isActive()",
|
||||
"[style.display]": 'isActive() ? "block" : "none"',
|
||||
},
|
||||
selector: 'app-tab',
|
||||
template: `<ng-content />`,
|
||||
host: {
|
||||
'[class.active]': 'isActive()',
|
||||
'[style.display]': 'isActive() ? "block" : "none"',
|
||||
},
|
||||
})
|
||||
export class Tab {
|
||||
label = input.required<string>();
|
||||
isActive = input(false);
|
||||
label = input.required<string>();
|
||||
isActive = input(false);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -149,27 +151,27 @@ export class Tab {
|
||||
Use `inject()` function instead of constructor injection:
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: "app-dashboard",
|
||||
template: `...`,
|
||||
selector: 'app-dashboard',
|
||||
template: `...`,
|
||||
})
|
||||
export class Dashboard {
|
||||
private router = inject(Router);
|
||||
private userService = inject(User);
|
||||
private config = inject(APP_CONFIG);
|
||||
private router = inject(Router);
|
||||
private userService = inject(User);
|
||||
private config = inject(APP_CONFIG);
|
||||
|
||||
// Optional injection
|
||||
private analytics = inject(Analytics, { optional: true });
|
||||
// Optional injection
|
||||
private analytics = inject(Analytics, { optional: true });
|
||||
|
||||
// Self-only injection
|
||||
private localService = inject(Local, { self: true });
|
||||
// Self-only injection
|
||||
private localService = inject(Local, { self: true });
|
||||
|
||||
navigateToProfile() {
|
||||
this.router.navigate(["/profile"]);
|
||||
}
|
||||
navigateToProfile() {
|
||||
this.router.navigate(['/profile']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -180,18 +182,20 @@ export class Dashboard {
|
||||
```typescript
|
||||
// Parent
|
||||
@Component({
|
||||
template: `<app-child [data]="parentData()" [config]="config" />`,
|
||||
template: `<app-child
|
||||
[data]="parentData()"
|
||||
[config]="config" />`,
|
||||
})
|
||||
export class Parent {
|
||||
parentData = signal({ name: "Test" });
|
||||
config = { theme: "dark" };
|
||||
parentData = signal({ name: 'Test' });
|
||||
config = { theme: 'dark' };
|
||||
}
|
||||
|
||||
// Child
|
||||
@Component({ selector: "app-child" })
|
||||
@Component({ selector: 'app-child' })
|
||||
export class Child {
|
||||
data = input.required<Data>();
|
||||
config = input<Config>();
|
||||
data = input.required<Data>();
|
||||
config = input<Config>();
|
||||
}
|
||||
```
|
||||
|
||||
@@ -200,25 +204,25 @@ export class Child {
|
||||
```typescript
|
||||
// Child
|
||||
@Component({
|
||||
selector: "app-child",
|
||||
template: `<button (click)="save()">Save</button>`,
|
||||
selector: 'app-child',
|
||||
template: `<button (click)="save()">Save</button>`,
|
||||
})
|
||||
export class Child {
|
||||
saved = output<Data>();
|
||||
saved = output<Data>();
|
||||
|
||||
save() {
|
||||
this.saved.emit({ id: 1, name: "Item" });
|
||||
}
|
||||
save() {
|
||||
this.saved.emit({ id: 1, name: 'Item' });
|
||||
}
|
||||
}
|
||||
|
||||
// Parent
|
||||
@Component({
|
||||
template: `<app-child (saved)="onSaved($event)" />`,
|
||||
template: `<app-child (saved)="onSaved($event)" />`,
|
||||
})
|
||||
export class Parent {
|
||||
onSaved(data: Data) {
|
||||
console.log("Saved:", data);
|
||||
}
|
||||
onSaved(data: Data) {
|
||||
console.log('Saved:', data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -226,39 +230,37 @@ export class Parent {
|
||||
|
||||
```typescript
|
||||
// Shared state service
|
||||
@Injectable({ providedIn: "root" })
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Cart {
|
||||
private items = signal<CartItem[]>([]);
|
||||
private items = signal<CartItem[]>([]);
|
||||
|
||||
readonly items$ = this.items.asReadonly();
|
||||
readonly total = computed(() =>
|
||||
this.items().reduce((sum, item) => sum + item.price, 0),
|
||||
);
|
||||
readonly items$ = this.items.asReadonly();
|
||||
readonly total = computed(() => this.items().reduce((sum, item) => sum + item.price, 0));
|
||||
|
||||
addItem(item: CartItem) {
|
||||
this.items.update((items) => [...items, item]);
|
||||
}
|
||||
addItem(item: CartItem) {
|
||||
this.items.update((items) => [...items, item]);
|
||||
}
|
||||
|
||||
removeItem(id: string) {
|
||||
this.items.update((items) => items.filter((i) => i.id !== id));
|
||||
}
|
||||
removeItem(id: string) {
|
||||
this.items.update((items) => items.filter((i) => i.id !== id));
|
||||
}
|
||||
}
|
||||
|
||||
// Component A
|
||||
@Component({ template: `<button (click)="add()">Add</button>` })
|
||||
export class Product {
|
||||
private cart = inject(Cart);
|
||||
product = input.required<Product>();
|
||||
private cart = inject(Cart);
|
||||
product = input.required<Product>();
|
||||
|
||||
add() {
|
||||
this.cart.addItem({ ...this.product(), quantity: 1 });
|
||||
}
|
||||
add() {
|
||||
this.cart.addItem({ ...this.product(), quantity: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// Component B
|
||||
@Component({ template: `<span>Total: {{ cart.total() }}</span>` })
|
||||
export class CartSummary {
|
||||
cart = inject(Cart);
|
||||
cart = inject(Cart);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -268,20 +270,20 @@ Using `@defer` for lazy loading:
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
template: `
|
||||
@defer (on viewport) {
|
||||
<app-heavy-chart [data]="chartData()" />
|
||||
} @placeholder {
|
||||
<div class="chart-placeholder">Loading chart...</div>
|
||||
} @loading (minimum 500ms) {
|
||||
<app-spinner />
|
||||
} @error {
|
||||
<p>Failed to load chart</p>
|
||||
}
|
||||
`,
|
||||
template: `
|
||||
@defer (on viewport) {
|
||||
<app-heavy-chart [data]="chartData()" />
|
||||
} @placeholder {
|
||||
<div class="chart-placeholder">Loading chart...</div>
|
||||
} @loading (minimum 500ms) {
|
||||
<app-spinner />
|
||||
} @error {
|
||||
<p>Failed to load chart</p>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class Dashboard {
|
||||
chartData = input.required<ChartData>();
|
||||
chartData = input.required<ChartData>();
|
||||
}
|
||||
```
|
||||
|
||||
@@ -297,16 +299,16 @@ Defer triggers:
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
template: `
|
||||
@defer (on interaction; prefetch on idle) {
|
||||
<app-comments [postId]="postId()" />
|
||||
} @placeholder {
|
||||
<button>Load Comments</button>
|
||||
}
|
||||
`,
|
||||
template: `
|
||||
@defer (on interaction; prefetch on idle) {
|
||||
<app-comments [postId]="postId()" />
|
||||
} @placeholder {
|
||||
<button>Load Comments</button>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class Post {
|
||||
postId = input.required<string>();
|
||||
postId = input.required<string>();
|
||||
}
|
||||
```
|
||||
|
||||
@@ -314,19 +316,19 @@ export class Post {
|
||||
|
||||
```typescript
|
||||
@Directive({
|
||||
selector: "[appHighlight]",
|
||||
host: {
|
||||
"[style.backgroundColor]": "color()",
|
||||
},
|
||||
selector: '[appHighlight]',
|
||||
host: {
|
||||
'[style.backgroundColor]': 'color()',
|
||||
},
|
||||
})
|
||||
export class Highlight {
|
||||
color = input("yellow", { alias: "appHighlight" });
|
||||
color = input('yellow', { alias: 'appHighlight' });
|
||||
}
|
||||
|
||||
// Usage on component
|
||||
@Component({
|
||||
imports: [Highlight],
|
||||
template: `<app-card appHighlight="lightblue" />`,
|
||||
imports: [Highlight],
|
||||
template: `<app-card appHighlight="lightblue" />`,
|
||||
})
|
||||
export class Page {}
|
||||
```
|
||||
@@ -335,24 +337,24 @@ export class Page {}
|
||||
|
||||
```typescript
|
||||
@Component({
|
||||
selector: "app-error-boundary",
|
||||
template: `
|
||||
@if (hasError()) {
|
||||
<div class="error">
|
||||
<h3>Something went wrong</h3>
|
||||
<button (click)="retry()">Retry</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ng-content />
|
||||
}
|
||||
`,
|
||||
selector: 'app-error-boundary',
|
||||
template: `
|
||||
@if (hasError()) {
|
||||
<div class="error">
|
||||
<h3>Something went wrong</h3>
|
||||
<button (click)="retry()">Retry</button>
|
||||
</div>
|
||||
} @else {
|
||||
<ng-content />
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ErrorBoundary {
|
||||
hasError = signal(false);
|
||||
private errorHandler = inject(ErrorHandler);
|
||||
hasError = signal(false);
|
||||
private errorHandler = inject(ErrorHandler);
|
||||
|
||||
retry() {
|
||||
this.hasError.set(false);
|
||||
}
|
||||
retry() {
|
||||
this.hasError.set(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user