diff --git a/apps/angular/4-typed-context-outlet/src/app/app.component.html b/apps/angular/4-typed-context-outlet/src/app/app.component.html
new file mode 100644
index 000000000..d9eac05a4
--- /dev/null
+++ b/apps/angular/4-typed-context-outlet/src/app/app.component.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
👤
+
+
Profile
+
{{ name }}
+
Age: {{ age }}
+
+
+
+
+
+
+
+
+
Students
+
+
+
+
{{ student.name.charAt(0).toUpperCase() }}
+
+
{{ student.name }}
+
{{ student.age }} years old
+
+
#{{ i + 1 }}
+
+
+
+
+
+
+
Cities Around the World
+
+
+
+
🏙️
+
+
{{ city.name }}
+
{{ city.country }}
+
+ {{ city.continent }} •
+ {{ city.population.toLocaleString() }} people •
+ {{ city.language }}
+
+
+
{{ i + 1 }}
+
+
+
+
diff --git a/apps/angular/4-typed-context-outlet/src/app/app.component.ts b/apps/angular/4-typed-context-outlet/src/app/app.component.ts
index d608bec2c..91edb00ff 100644
--- a/apps/angular/4-typed-context-outlet/src/app/app.component.ts
+++ b/apps/angular/4-typed-context-outlet/src/app/app.component.ts
@@ -1,44 +1,51 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
-import { ListComponent } from './list.component';
-import { PersonComponent } from './person.component';
+import { ListComponent, ListDirective } from './list.component';
+import { Person, PersonComponent, PersonDirective } from './person.component';
-@Component({
- imports: [PersonComponent, ListComponent],
- selector: 'app-root',
- template: `
-
-
- {{ name }}: {{ age }}
-
-
+interface Student {
+ readonly name: string;
+ readonly age: number;
+}
-
-
- {{ student.name }}: {{ student.age }} - {{ i }}
-
-
+interface City {
+ readonly name: string;
+ readonly country: string;
+ readonly population: number;
+ readonly continent: string;
+ readonly language: string;
+}
-
-
- {{ city.name }}: {{ city.country }} - {{ i }}
-
-
- `,
+@Component({
+ imports: [PersonComponent, ListComponent, PersonDirective, ListDirective],
+ selector: 'app-root',
+ templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
- person = {
+ person: Person = {
name: 'toto',
age: 3,
};
- students = [
+ students: Student[] = [
{ name: 'toto', age: 3 },
{ name: 'titi', age: 4 },
];
- cities = [
- { name: 'Paris', country: 'France' },
- { name: 'Berlin', country: 'Germany' },
+ cities: City[] = [
+ {
+ name: 'Paris',
+ country: 'France',
+ population: 2161000,
+ continent: 'Europe',
+ language: 'French',
+ },
+ {
+ name: 'Berlin',
+ country: 'Germany',
+ population: 3645000,
+ continent: 'Europe',
+ language: 'German',
+ },
];
}
diff --git a/apps/angular/4-typed-context-outlet/src/app/list.component.ts b/apps/angular/4-typed-context-outlet/src/app/list.component.ts
index 57fa4e361..b1051c641 100644
--- a/apps/angular/4-typed-context-outlet/src/app/list.component.ts
+++ b/apps/angular/4-typed-context-outlet/src/app/list.component.ts
@@ -3,10 +3,33 @@ import {
ChangeDetectionStrategy,
Component,
contentChild,
+ Directive,
input,
+ Signal,
TemplateRef,
} from '@angular/core';
+interface ListContext {
+ readonly $implicit: T;
+ // added list property only to match it with sugar syntax (which I don't like)
+ readonly list: T;
+ readonly index: number;
+}
+
+@Directive({
+ selector: 'ng-template[list]',
+})
+export class ListDirective {
+ readonly list = input.required();
+
+ static ngTemplateContextGuard(
+ dir: ListDirective,
+ ctx: unknown,
+ ): ctx is ListContext {
+ return true;
+ }
+}
+
@Component({
selector: 'list',
template: `
@@ -14,7 +37,7 @@ import {
}
@@ -23,8 +46,11 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgTemplateOutlet],
})
-export class ListComponent {
- list = input.required();
+export class ListComponent {
+ readonly list = input.required();
- listTemplateRef = contentChild('listRef', { read: TemplateRef });
+ // if required -> emptyRef becomes dead code
+ protected readonly listTemplateRef: Signal<
+ TemplateRef> | undefined
+ > = contentChild(ListDirective, { read: TemplateRef });
}
diff --git a/apps/angular/4-typed-context-outlet/src/app/person.component.ts b/apps/angular/4-typed-context-outlet/src/app/person.component.ts
index d9f5e7520..52b5277a3 100644
--- a/apps/angular/4-typed-context-outlet/src/app/person.component.ts
+++ b/apps/angular/4-typed-context-outlet/src/app/person.component.ts
@@ -1,5 +1,34 @@
import { NgTemplateOutlet } from '@angular/common';
-import { Component, contentChild, input, TemplateRef } from '@angular/core';
+import {
+ Component,
+ contentChild,
+ Directive,
+ input,
+ Signal,
+ TemplateRef,
+} from '@angular/core';
+
+interface PersonContext {
+ readonly $implicit: string;
+ readonly age: number;
+}
+
+export interface Person {
+ readonly name: string;
+ readonly age: number;
+}
+
+@Directive({
+ selector: 'ng-template[person]',
+})
+export class PersonDirective {
+ static ngTemplateContextGuard(
+ dir: PersonDirective,
+ ctx: unknown,
+ ): ctx is PersonContext {
+ return true;
+ }
+}
@Component({
imports: [NgTemplateOutlet],
@@ -15,7 +44,9 @@ import { Component, contentChild, input, TemplateRef } from '@angular/core';
`,
})
export class PersonComponent {
- person = input.required<{ name: string; age: number }>();
-
- personTemplateRef = contentChild('personRef', { read: TemplateRef });
+ readonly person = input.required();
+ protected readonly personTemplateRef: Signal> =
+ contentChild.required(PersonDirective, {
+ read: TemplateRef,
+ });
}
diff --git a/apps/angular/4-typed-context-outlet/src/styles.scss b/apps/angular/4-typed-context-outlet/src/styles.scss
index 90d4ee007..1c2deb3f6 100644
--- a/apps/angular/4-typed-context-outlet/src/styles.scss
+++ b/apps/angular/4-typed-context-outlet/src/styles.scss
@@ -1 +1,209 @@
/* You can add global styles to this file, and also import other style files */
+
+.person-section {
+ background: linear-gradient(to right, #f093fb 0%, #f5576c 100%);
+ padding: 20px 28px;
+ margin: 20px 0;
+ border-radius: 16px;
+ box-shadow: 0 8px 16px rgba(245, 87, 108, 0.3);
+ position: relative;
+ overflow: hidden;
+
+ .person-card {
+ background: rgba(255, 255, 255, 0.95);
+ padding: 20px 24px;
+ border-radius: 12px;
+ display: inline-flex;
+ align-items: center;
+ gap: 20px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ backdrop-filter: blur(10px);
+ min-width: 300px;
+
+ .person-icon {
+ width: 60px;
+ height: 60px;
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+ border-radius: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 2em;
+ box-shadow: 0 4px 8px rgba(245, 87, 108, 0.3);
+ }
+
+ .person-data {
+ .person-label {
+ font-size: 0.75em;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: #999;
+ margin-bottom: 4px;
+ font-weight: 600;
+ }
+
+ .person-name {
+ font-size: 1.4em;
+ font-weight: bold;
+ color: #f5576c;
+ margin-bottom: 6px;
+ }
+
+ .person-age {
+ font-size: 1.1em;
+ color: #666;
+
+ .age-value {
+ font-weight: bold;
+ color: #f093fb;
+ }
+ }
+ }
+ }
+}
+
+.students-section {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ padding: 24px;
+ margin: 20px 0;
+ border-radius: 12px;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+
+ h3 {
+ margin: 0 0 16px 0;
+ color: white;
+ font-family: system-ui, -apple-system, sans-serif;
+ font-size: 1.5em;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ }
+
+ .student-card {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 16px;
+ margin: 12px 0;
+ background-color: white;
+ border-radius: 8px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ }
+
+ .student-avatar {
+ width: 50px;
+ height: 50px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-weight: bold;
+ font-size: 1.2em;
+ flex-shrink: 0;
+ }
+
+ .student-info {
+ flex: 1;
+
+ .student-name {
+ font-size: 1.1em;
+ font-weight: bold;
+ color: #667eea;
+ margin-bottom: 4px;
+ }
+
+ .student-age {
+ color: #666;
+ font-size: 0.95em;
+ }
+ }
+
+ .student-badge {
+ background-color: #667eea;
+ color: white;
+ padding: 4px 12px;
+ border-radius: 20px;
+ font-size: 0.85em;
+ font-weight: bold;
+ }
+}
+
+.cities-section {
+ background-color: #fff;
+ padding: 24px;
+ margin: 20px 0;
+ border: 2px solid #e0e0e0;
+ border-radius: 4px;
+
+ h3 {
+ margin: 0 0 16px 0;
+ color: #333;
+ font-family: 'Georgia', serif;
+ font-size: 1.8em;
+ font-weight: normal;
+ border-bottom: 3px solid #ff6b6b;
+ padding-bottom: 8px;
+ }
+
+ .city-row {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 16px;
+ padding: 12px 16px;
+ margin: 8px 0;
+ background-color: #f9f9f9;
+ border-left: 4px solid #ff6b6b;
+
+ .city-flag {
+ font-size: 2em;
+ }
+
+ .city-details {
+ .city-name {
+ font-size: 1.2em;
+ font-weight: 600;
+ color: #ff6b6b;
+ margin-bottom: 2px;
+ }
+
+ .city-country {
+ color: #888;
+ font-size: 0.9em;
+ font-style: italic;
+ margin-bottom: 4px;
+ }
+
+ .city-meta {
+ font-size: 0.85em;
+ color: #666;
+
+ .city-continent {
+ font-weight: 600;
+ }
+
+ .city-population {
+ color: #ff6b6b;
+ }
+
+ .city-language {
+ font-style: italic;
+ }
+ }
+ }
+
+ .city-position {
+ background-color: #ff6b6b;
+ color: white;
+ width: 32px;
+ height: 32px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: bold;
+ font-size: 0.9em;
+ }
+ }
+}