From 7b23a5aa19995c1df148cdf60161234f509a7f77 Mon Sep 17 00:00:00 2001 From: My Name Date: Fri, 2 Jan 2026 11:41:28 +0100 Subject: [PATCH] chore: 4-typed-context-outlet --- .../src/app/app.component.html | 56 +++++ .../src/app/app.component.ts | 61 ++--- .../src/app/list.component.ts | 34 ++- .../src/app/person.component.ts | 39 +++- .../4-typed-context-outlet/src/styles.scss | 208 ++++++++++++++++++ 5 files changed, 363 insertions(+), 35 deletions(-) create mode 100644 apps/angular/4-typed-context-outlet/src/app/app.component.html 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; + } + } +}