Skip to content

Commit 1443e21

Browse files
committed
📦 refactor(avartar): using ngOptimizedImage directive
1 parent 3ebec3b commit 1443e21

File tree

7 files changed

+146
-52
lines changed

7 files changed

+146
-52
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,5 @@ Thumbs.db
5050
.clinerules
5151
.clineignore
5252
memory-bank
53-
.amazonq
53+
.amazonq
54+
.kilocode

apps/web/public/components/avatar/doc/api.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44

55
| Property | Description | Type | Default |
66
| ------------- | ------------------------------------------------- | ------------------------------------------- | --------- |
7-
| `[zSize]` | Avatar size variant | `sm \| default \| md \| lg \| xl \| number` | `default` |
8-
| `[zShape]` | Avatar shape | `circle \| rounded \| square` | `circle` |
9-
| `[zStatus]` | Status indicator badge | `online \| offline \| doNotDisturb \| away` | |
10-
| `[zSrc]` | Image source URL | `string` | |
7+
| `[class]` | Additional CSS classes | `string` | `''` |
118
| `[zAlt]` | Image alt text for accessibility | `string` | `''` |
129
| `[zFallback]` | Fallback text displayed while loading or on error | `string` | `''` |
13-
| `[class]` | Additional CSS classes | `string` | `''` |
10+
| `[zPriority]` | Should image load with high priority | `boolean` | `false` |
11+
| `[zShape]` | Avatar shape | `circle \| rounded \| square` | `circle` |
12+
| `[zSize]` | Avatar size variant | `sm \| default \| md \| lg \| xl \| number` | `default` |
13+
| `[zSrc]` | Image source URL | `string \| SafeUrl` | `''` |
14+
| `[zStatus]` | Status indicator badge | `online \| offline \| doNotDisturb \| away` | |
1415

1516
[z-avatar-group] Component
1617

apps/web/public/installation/manual/avatar.md

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,43 @@
11

22

33
```angular-ts title="avatar.component.ts" expandable="true" expandableTitle="Expand" copyButton showLineNumbers
4-
import { ChangeDetectionStrategy, Component, computed, input, signal, ViewEncapsulation } from '@angular/core';
5-
6-
import { avatarVariants, imageVariants, type ZardImageVariants, type ZardAvatarVariants } from './avatar.variants';
4+
import { NgOptimizedImage } from '@angular/common';
5+
import {
6+
booleanAttribute,
7+
ChangeDetectionStrategy,
8+
Component,
9+
computed,
10+
effect,
11+
input,
12+
signal,
13+
ViewEncapsulation,
14+
} from '@angular/core';
15+
import type { SafeUrl } from '@angular/platform-browser';
716
817
import { mergeClasses } from '@/shared/utils/merge-classes';
918
19+
import { avatarVariants, imageVariants, type ZardAvatarVariants, type ZardImageVariants } from './avatar.variants';
20+
1021
export type ZardAvatarStatus = 'online' | 'offline' | 'doNotDisturb' | 'away';
1122
1223
@Component({
1324
selector: 'z-avatar, [z-avatar]',
14-
standalone: true,
25+
imports: [NgOptimizedImage],
1526
template: `
1627
@if (zFallback() && (!zSrc() || !imageLoaded())) {
1728
<span class="absolute z-0 m-auto text-base">{{ zFallback() }}</span>
1829
}
1930
2031
@if (zSrc() && !imageError()) {
2132
<img
22-
[src]="zSrc()"
33+
fill
34+
sizes="100%"
2335
[alt]="zAlt()"
2436
[class]="imgClasses()"
25-
[hidden]="!imageLoaded()"
26-
(load)="onImageLoad()"
37+
[ngSrc]="zSrc()"
38+
[priority]="zPriority()"
2739
(error)="onImageError()"
40+
(load)="onImageLoad()"
2841
/>
2942
}
3043
@@ -41,7 +54,7 @@ export type ZardAvatarStatus = 'online' | 'offline' | 'doNotDisturb' | 'away';
4154
stroke-width="2"
4255
stroke-linecap="round"
4356
stroke-linejoin="round"
44-
class="absolute -right-[5px] -bottom-[5px] z-20 h-5 w-5 text-green-500"
57+
class="absolute -right-1.25 -bottom-1.25 z-20 h-5 w-5 text-green-500"
4558
>
4659
<circle cx="12" cy="12" r="10" fill="currentColor" />
4760
</svg>
@@ -57,7 +70,7 @@ export type ZardAvatarStatus = 'online' | 'offline' | 'doNotDisturb' | 'away';
5770
stroke-width="2"
5871
stroke-linecap="round"
5972
stroke-linejoin="round"
60-
class="absolute -right-[5px] -bottom-[5px] z-20 h-5 w-5 text-red-500"
73+
class="absolute -right-1.25 -bottom-1.25 z-20 h-5 w-5 text-red-500"
6174
>
6275
<circle cx="12" cy="12" r="10" fill="currentColor" />
6376
</svg>
@@ -73,7 +86,7 @@ export type ZardAvatarStatus = 'online' | 'offline' | 'doNotDisturb' | 'away';
7386
stroke-width="2"
7487
stroke-linecap="round"
7588
stroke-linejoin="round"
76-
class="absolute -right-[5px] -bottom-[5px] z-20 h-5 w-5 text-red-500"
89+
class="absolute -right-1.25 -bottom-1.25 z-20 h-5 w-5 text-red-500"
7790
>
7891
<circle cx="12" cy="12" r="10" />
7992
<path d="M8 12h8" fill="currentColor" />
@@ -90,7 +103,7 @@ export type ZardAvatarStatus = 'online' | 'offline' | 'doNotDisturb' | 'away';
90103
stroke-width="2"
91104
stroke-linecap="round"
92105
stroke-linejoin="round"
93-
class="absolute -right-[5px] -bottom-[5px] z-20 h-5 w-5 rotate-y-180 text-yellow-400"
106+
class="absolute -right-1.25 -bottom-1.25 z-20 h-5 w-5 rotate-y-180 text-yellow-400"
94107
>
95108
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" fill="currentColor" />
96109
</svg>
@@ -110,17 +123,26 @@ export type ZardAvatarStatus = 'online' | 'offline' | 'doNotDisturb' | 'away';
110123
exportAs: 'zAvatar',
111124
})
112125
export class ZardAvatarComponent {
113-
readonly zStatus = input<ZardAvatarStatus>();
114-
readonly zShape = input<ZardImageVariants['zShape']>('circle');
115-
readonly zSize = input<ZardAvatarVariants['zSize'] | number>('default');
116-
readonly zSrc = input<string>();
126+
readonly class = input<string>('');
117127
readonly zAlt = input<string>('');
118128
readonly zFallback = input<string>('');
129+
readonly zPriority = input(false, { transform: booleanAttribute });
130+
readonly zShape = input<ZardImageVariants['zShape']>('circle');
131+
readonly zSize = input<ZardAvatarVariants['zSize'] | number>('default');
132+
readonly zSrc = input<string | SafeUrl>('');
133+
readonly zStatus = input<ZardAvatarStatus>();
119134
120-
readonly class = input<string>('');
135+
readonly imageError = signal(false);
136+
readonly imageLoaded = signal(false);
121137
122-
protected readonly imageError = signal(false);
123-
protected readonly imageLoaded = signal(false);
138+
constructor() {
139+
effect(() => {
140+
// Reset image state when zSrc changes
141+
this.zSrc();
142+
this.imageError.set(false);
143+
this.imageLoaded.set(false);
144+
});
145+
}
124146
125147
protected readonly containerClasses = computed(() => {
126148
const size = this.zSize();

apps/web/public/r/avatar.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"files": [
55
{
66
"name": "avatar.component.ts",
7-
"content": "import { ChangeDetectionStrategy, Component, computed, input, signal, ViewEncapsulation } from '@angular/core';\n\nimport { avatarVariants, imageVariants, type ZardImageVariants, type ZardAvatarVariants } from './avatar.variants';\n\nimport { mergeClasses } from '@/shared/utils/merge-classes';\n\nexport type ZardAvatarStatus = 'online' | 'offline' | 'doNotDisturb' | 'away';\n\n@Component({\n selector: 'z-avatar, [z-avatar]',\n standalone: true,\n template: `\n @if (zFallback() && (!zSrc() || !imageLoaded())) {\n <span class=\"absolute z-0 m-auto text-base\">{{ zFallback() }}</span>\n }\n\n @if (zSrc() && !imageError()) {\n <img\n [src]=\"zSrc()\"\n [alt]=\"zAlt()\"\n [class]=\"imgClasses()\"\n [hidden]=\"!imageLoaded()\"\n (load)=\"onImageLoad()\"\n (error)=\"onImageError()\"\n />\n }\n\n @if (zStatus()) {\n @switch (zStatus()) {\n @case ('online') {\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n stroke=\"white\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"absolute -right-[5px] -bottom-[5px] z-20 h-5 w-5 text-green-500\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"currentColor\" />\n </svg>\n }\n @case ('offline') {\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n stroke=\"white\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"absolute -right-[5px] -bottom-[5px] z-20 h-5 w-5 text-red-500\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"currentColor\" />\n </svg>\n }\n @case ('doNotDisturb') {\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n stroke=\"white\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"absolute -right-[5px] -bottom-[5px] z-20 h-5 w-5 text-red-500\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"10\" />\n <path d=\"M8 12h8\" fill=\"currentColor\" />\n </svg>\n }\n @case ('away') {\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n stroke=\"white\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"absolute -right-[5px] -bottom-[5px] z-20 h-5 w-5 rotate-y-180 text-yellow-400\"\n >\n <path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\" fill=\"currentColor\" />\n </svg>\n }\n }\n }\n `,\n changeDetection: ChangeDetectionStrategy.OnPush,\n encapsulation: ViewEncapsulation.None,\n host: {\n '[class]': 'containerClasses()',\n '[style.width]': 'customSize()',\n '[style.height]': 'customSize()',\n '[attr.data-slot]': '\"avatar\"',\n '[attr.data-status]': 'zStatus() ?? null',\n },\n exportAs: 'zAvatar',\n})\nexport class ZardAvatarComponent {\n readonly zStatus = input<ZardAvatarStatus>();\n readonly zShape = input<ZardImageVariants['zShape']>('circle');\n readonly zSize = input<ZardAvatarVariants['zSize'] | number>('default');\n readonly zSrc = input<string>();\n readonly zAlt = input<string>('');\n readonly zFallback = input<string>('');\n\n readonly class = input<string>('');\n\n protected readonly imageError = signal(false);\n protected readonly imageLoaded = signal(false);\n\n protected readonly containerClasses = computed(() => {\n const size = this.zSize();\n const zSize = typeof size === 'number' ? undefined : (size as ZardAvatarVariants['zSize']);\n\n return mergeClasses(avatarVariants({ zShape: this.zShape(), zSize }), this.class());\n });\n\n protected readonly customSize = computed(() => {\n const size = this.zSize();\n return typeof size === 'number' ? `${size}px` : null;\n });\n\n protected readonly imgClasses = computed(() => mergeClasses(imageVariants({ zShape: this.zShape() })));\n\n protected onImageLoad(): void {\n this.imageLoaded.set(true);\n this.imageError.set(false);\n }\n\n protected onImageError(): void {\n this.imageError.set(true);\n this.imageLoaded.set(false);\n }\n}\n"
7+
"content": "import { NgOptimizedImage } from '@angular/common';\nimport {\n booleanAttribute,\n ChangeDetectionStrategy,\n Component,\n computed,\n effect,\n input,\n signal,\n ViewEncapsulation,\n} from '@angular/core';\nimport type { SafeUrl } from '@angular/platform-browser';\n\nimport { mergeClasses } from '@/shared/utils/merge-classes';\n\nimport { avatarVariants, imageVariants, type ZardAvatarVariants, type ZardImageVariants } from './avatar.variants';\n\nexport type ZardAvatarStatus = 'online' | 'offline' | 'doNotDisturb' | 'away';\n\n@Component({\n selector: 'z-avatar, [z-avatar]',\n imports: [NgOptimizedImage],\n template: `\n @if (zFallback() && (!zSrc() || !imageLoaded())) {\n <span class=\"absolute z-0 m-auto text-base\">{{ zFallback() }}</span>\n }\n\n @if (zSrc() && !imageError()) {\n <img\n fill\n sizes=\"100%\"\n [alt]=\"zAlt()\"\n [class]=\"imgClasses()\"\n [ngSrc]=\"zSrc()\"\n [priority]=\"zPriority()\"\n (error)=\"onImageError()\"\n (load)=\"onImageLoad()\"\n />\n }\n\n @if (zStatus()) {\n @switch (zStatus()) {\n @case ('online') {\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n stroke=\"white\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"absolute -right-1.25 -bottom-1.25 z-20 h-5 w-5 text-green-500\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"currentColor\" />\n </svg>\n }\n @case ('offline') {\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n stroke=\"white\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"absolute -right-1.25 -bottom-1.25 z-20 h-5 w-5 text-red-500\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"10\" fill=\"currentColor\" />\n </svg>\n }\n @case ('doNotDisturb') {\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n stroke=\"white\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"absolute -right-1.25 -bottom-1.25 z-20 h-5 w-5 text-red-500\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"10\" />\n <path d=\"M8 12h8\" fill=\"currentColor\" />\n </svg>\n }\n @case ('away') {\n <svg\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"currentColor\"\n stroke=\"white\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n class=\"absolute -right-1.25 -bottom-1.25 z-20 h-5 w-5 rotate-y-180 text-yellow-400\"\n >\n <path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\" fill=\"currentColor\" />\n </svg>\n }\n }\n }\n `,\n changeDetection: ChangeDetectionStrategy.OnPush,\n encapsulation: ViewEncapsulation.None,\n host: {\n '[class]': 'containerClasses()',\n '[style.width]': 'customSize()',\n '[style.height]': 'customSize()',\n '[attr.data-slot]': '\"avatar\"',\n '[attr.data-status]': 'zStatus() ?? null',\n },\n exportAs: 'zAvatar',\n})\nexport class ZardAvatarComponent {\n readonly class = input<string>('');\n readonly zAlt = input<string>('');\n readonly zFallback = input<string>('');\n readonly zPriority = input(false, { transform: booleanAttribute });\n readonly zShape = input<ZardImageVariants['zShape']>('circle');\n readonly zSize = input<ZardAvatarVariants['zSize'] | number>('default');\n readonly zSrc = input<string | SafeUrl>('');\n readonly zStatus = input<ZardAvatarStatus>();\n\n readonly imageError = signal(false);\n readonly imageLoaded = signal(false);\n\n constructor() {\n effect(() => {\n // Reset image state when zSrc changes\n this.zSrc();\n this.imageError.set(false);\n this.imageLoaded.set(false);\n });\n }\n\n protected readonly containerClasses = computed(() => {\n const size = this.zSize();\n const zSize = typeof size === 'number' ? undefined : (size as ZardAvatarVariants['zSize']);\n\n return mergeClasses(avatarVariants({ zShape: this.zShape(), zSize }), this.class());\n });\n\n protected readonly customSize = computed(() => {\n const size = this.zSize();\n return typeof size === 'number' ? `${size}px` : null;\n });\n\n protected readonly imgClasses = computed(() => mergeClasses(imageVariants({ zShape: this.zShape() })));\n\n protected onImageLoad(): void {\n this.imageLoaded.set(true);\n this.imageError.set(false);\n }\n\n protected onImageError(): void {\n this.imageError.set(true);\n this.imageLoaded.set(false);\n }\n}\n"
88
},
99
{
1010
"name": "avatar-group.component.ts",

libs/zard/src/lib/shared/components/avatar/avatar.component.spec.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { Component } from '@angular/core';
2-
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { type ComponentFixture, TestBed } from '@angular/core/testing';
33
import { By } from '@angular/platform-browser';
44

5-
import { ZardAvatarComponent, ZardAvatarStatus } from './avatar.component';
6-
import { ZardAvatarVariants, ZardImageVariants } from './avatar.variants';
5+
import { ZardAvatarComponent, type ZardAvatarStatus } from './avatar.component';
6+
import { type ZardAvatarVariants, type ZardImageVariants } from './avatar.variants';
77

88
@Component({
99
imports: [ZardAvatarComponent],
10-
standalone: true,
1110
template: `
1211
<z-avatar
1312
[zSize]="zSize"
@@ -143,6 +142,54 @@ describe('ZardAvatarComponent', () => {
143142
expect(fallbackElement).toBeTruthy();
144143
expect(fallbackElement.nativeElement.textContent.trim()).toBe('AB');
145144
});
145+
146+
it('should handle image load event correctly', () => {
147+
// Set a valid image path
148+
hostComponent.zSrc = 'valid-image.jpg';
149+
fixture.detectChanges();
150+
151+
// Locate the img element and verify src attribute
152+
const imgElement = fixture.debugElement.query(By.css('img')).nativeElement;
153+
expect(imgElement.src).toContain('valid-image.jpg');
154+
155+
// Simulate successful load event
156+
imgElement.dispatchEvent(new Event('load'));
157+
fixture.detectChanges();
158+
159+
// Verify load state
160+
expect(avatarComponent.imageLoaded()).toBe(true);
161+
expect(avatarComponent.imageError()).toBe(false);
162+
});
163+
164+
it('should handle image error event and reset state when zSrc changes', () => {
165+
// Set an invalid image path
166+
hostComponent.zSrc = 'invalid-image.jpg';
167+
fixture.detectChanges();
168+
169+
// Locate the img element
170+
const imgElement = fixture.debugElement.query(By.css('img')).nativeElement;
171+
expect(imgElement.src).toContain('invalid-image.jpg');
172+
173+
// Dispatch error event
174+
imgElement.dispatchEvent(new Event('error'));
175+
fixture.detectChanges();
176+
177+
// Verify error state
178+
expect(avatarComponent.imageError()).toBe(true);
179+
expect(avatarComponent.imageLoaded()).toBe(false);
180+
181+
// Change to a fallback image path
182+
hostComponent.zSrc = 'fallback-image.jpg';
183+
fixture.detectChanges();
184+
185+
// Verify the img element's src updates to the fallback path
186+
const imgElementAfterChange = fixture.debugElement.query(By.css('img')).nativeElement;
187+
expect(imgElementAfterChange.src).toContain('fallback-image.jpg');
188+
189+
// Verify error state is reset
190+
expect(avatarComponent.imageError()).toBe(false);
191+
expect(avatarComponent.imageLoaded()).toBe(false);
192+
});
146193
});
147194

148195
describe('Status indicators', () => {

0 commit comments

Comments
 (0)