From 84b6aaac33d18f65984e7c3fbcf68782e1e2a319 Mon Sep 17 00:00:00 2001 From: Suneeh Date: Mon, 23 Mar 2026 21:22:40 +0100 Subject: [PATCH] feat (avatar) introduction --- projects/components/avatar/index.ts | 3 + projects/components/avatar/ng-package.json | 5 + projects/components/avatar/public_api.ts | 1 + .../avatar/src/avatar.component.html | 11 + .../avatar/src/avatar.component.scss | 108 +++++++ .../avatar/src/avatar.component.spec.ts | 303 ++++++++++++++++++ .../components/avatar/src/avatar.component.ts | 56 ++++ .../src/app/app.component.html | 1 + .../src/app/app.config.ts | 4 + .../src/app/avatar-demo/avatar-demo.page.html | 111 +++++++ .../src/app/avatar-demo/avatar-demo.page.ts | 40 +++ 11 files changed, 643 insertions(+) create mode 100644 projects/components/avatar/index.ts create mode 100644 projects/components/avatar/ng-package.json create mode 100644 projects/components/avatar/public_api.ts create mode 100644 projects/components/avatar/src/avatar.component.html create mode 100644 projects/components/avatar/src/avatar.component.scss create mode 100644 projects/components/avatar/src/avatar.component.spec.ts create mode 100644 projects/components/avatar/src/avatar.component.ts create mode 100644 projects/zvoove-components-demo/src/app/avatar-demo/avatar-demo.page.html create mode 100644 projects/zvoove-components-demo/src/app/avatar-demo/avatar-demo.page.ts diff --git a/projects/components/avatar/index.ts b/projects/components/avatar/index.ts new file mode 100644 index 00000000..c74f953a --- /dev/null +++ b/projects/components/avatar/index.ts @@ -0,0 +1,3 @@ +// export what ./public_api exports so we can import with the lib name like this: +// import { ModuleA } from 'libname' +export * from './public_api'; diff --git a/projects/components/avatar/ng-package.json b/projects/components/avatar/ng-package.json new file mode 100644 index 00000000..1dc0b0bd --- /dev/null +++ b/projects/components/avatar/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "index.ts" + } +} diff --git a/projects/components/avatar/public_api.ts b/projects/components/avatar/public_api.ts new file mode 100644 index 00000000..9f43a8cd --- /dev/null +++ b/projects/components/avatar/public_api.ts @@ -0,0 +1 @@ +export { ZvAvatar } from './src/avatar.component'; diff --git a/projects/components/avatar/src/avatar.component.html b/projects/components/avatar/src/avatar.component.html new file mode 100644 index 00000000..a18a3324 --- /dev/null +++ b/projects/components/avatar/src/avatar.component.html @@ -0,0 +1,11 @@ +@switch (displayType()) { + @case ('avatar') { + person + } + @case ('initials') { + {{ initials() }} + } + @case ('image') { + + } +} diff --git a/projects/components/avatar/src/avatar.component.scss b/projects/components/avatar/src/avatar.component.scss new file mode 100644 index 00000000..8e081c68 --- /dev/null +++ b/projects/components/avatar/src/avatar.component.scss @@ -0,0 +1,108 @@ +zv-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + user-select: none; + flex-shrink: 0; +} + +// Sizes +.zv-avatar--small { + width: 24px; + height: 24px; + + .zv-avatar__icon { + font-size: 15px; + width: 15px; + height: 15px; + } + + .zv-avatar__initials { + font-size: 12px; + } +} + +.zv-avatar--medium { + width: 40px; + height: 40px; + + .zv-avatar__icon { + font-size: 25px; + width: 25px; + height: 25px; + } + + .zv-avatar__initials { + font-size: 16px; + } +} + +.zv-avatar--large { + width: 120px; + height: 120px; + + .zv-avatar__icon { + font-size: 80px; + width: 80px; + height: 80px; + } + + .zv-avatar__initials { + font-size: 45px; + } +} + +// Variants +.zv-avatar--round { + border-radius: 50%; +} + +.zv-avatar--square { + &.zv-avatar--small { + border-radius: 2px; + } + + &.zv-avatar--medium { + border-radius: 4px; + } + + &.zv-avatar--large { + border-radius: 8px; + } +} + +// Type colors +.zv-avatar--type-avatar { + background-color: #dfe0ff; + color: var(--zv-components-font); +} + +.zv-avatar--type-initials { + background-color: #dfe0ff; + color: var(--zv-components-font); +} + +.zv-avatar--type-image { + background-color: transparent; +} + +// Elements +.zv-avatar__icon { + display: flex; + align-items: center; + justify-content: center; +} + +.zv-avatar__initials { + font-weight: 500; + line-height: 1; + letter-spacing: 0.02em; +} + +.zv-avatar__image { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; +} diff --git a/projects/components/avatar/src/avatar.component.spec.ts b/projects/components/avatar/src/avatar.component.spec.ts new file mode 100644 index 00000000..df6914a4 --- /dev/null +++ b/projects/components/avatar/src/avatar.component.spec.ts @@ -0,0 +1,303 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ZvAvatar } from './avatar.component'; + +@Component({ + selector: 'zv-test-component', + template: ` + + `, + // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection + changeDetection: ChangeDetectionStrategy.Default, + imports: [ZvAvatar], +}) +class TestComponent { + type: 'initials' | 'avatar' | 'image' = 'avatar'; + size: 'small' | 'medium' | 'large' = 'medium'; + variant: 'round' | 'square' = 'round'; + name = ''; + image = ''; + initialsAmount: 1 | 2 = 1; +} + +describe('ZvAvatar', () => { + let fixture: ComponentFixture; + let component: TestComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestComponent], + }).compileComponents(); + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + function avatarEl(): HTMLElement { + return fixture.debugElement.query(By.directive(ZvAvatar)).nativeElement; + } + + describe('default rendering', () => { + it('should render a person icon by default', () => { + const icon = fixture.debugElement.query(By.css('mat-icon')); + expect(icon).toBeTruthy(); + expect(icon.nativeElement.textContent.trim()).toBe('person'); + }); + + it('should have default host classes', () => { + const el = avatarEl(); + expect(el.classList).toContain('zv-avatar'); + expect(el.classList).toContain('zv-avatar--medium'); + expect(el.classList).toContain('zv-avatar--round'); + expect(el.classList).toContain('zv-avatar--type-avatar'); + }); + + it('should set role="img" on host', () => { + expect(avatarEl().getAttribute('role')).toBe('img'); + }); + + it('should set aria-label to "Avatar" when no name is provided', () => { + expect(avatarEl().getAttribute('aria-label')).toBe('Avatar'); + }); + }); + + describe('type: avatar', () => { + it('should render a mat-icon with "person"', () => { + component.type = 'avatar'; + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('mat-icon')); + expect(icon.nativeElement.textContent.trim()).toBe('person'); + }); + }); + + describe('type: initials', () => { + it('should display a single initial from a one-word name', () => { + component.type = 'initials'; + component.name = 'Alice'; + fixture.detectChanges(); + const span = fixture.debugElement.query(By.css('.zv-avatar__initials')); + expect(span.nativeElement.textContent.trim()).toBe('A'); + }); + + it('should display a single initial from a multi-word name with initialsAmount 1', () => { + component.type = 'initials'; + component.name = 'John Doe'; + component.initialsAmount = 1; + fixture.detectChanges(); + const span = fixture.debugElement.query(By.css('.zv-avatar__initials')); + expect(span.nativeElement.textContent.trim()).toBe('J'); + }); + + it('should display two initials from a multi-word name with initialsAmount 2', () => { + component.type = 'initials'; + component.name = 'John Doe'; + component.initialsAmount = 2; + fixture.detectChanges(); + const span = fixture.debugElement.query(By.css('.zv-avatar__initials')); + expect(span.nativeElement.textContent.trim()).toBe('JD'); + }); + + it('should use first and last word for 3+ word names with initialsAmount 2', () => { + component.type = 'initials'; + component.name = 'John Michael Doe'; + component.initialsAmount = 2; + fixture.detectChanges(); + const span = fixture.debugElement.query(By.css('.zv-avatar__initials')); + expect(span.nativeElement.textContent.trim()).toBe('JD'); + }); + + it('should return 1 initial for single-word name even with initialsAmount 2', () => { + component.type = 'initials'; + component.name = 'Madonna'; + component.initialsAmount = 2; + fixture.detectChanges(); + const span = fixture.debugElement.query(By.css('.zv-avatar__initials')); + expect(span.nativeElement.textContent.trim()).toBe('M'); + }); + + it('should fall back to person icon when name is empty', () => { + component.type = 'initials'; + component.name = ''; + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('mat-icon')); + expect(icon).toBeTruthy(); + expect(icon.nativeElement.textContent.trim()).toBe('person'); + expect(avatarEl().classList).toContain('zv-avatar--type-avatar'); + }); + + it('should fall back to person icon when name is whitespace only', () => { + component.type = 'initials'; + component.name = ' '; + fixture.detectChanges(); + const icon = fixture.debugElement.query(By.css('mat-icon')); + expect(icon).toBeTruthy(); + expect(icon.nativeElement.textContent.trim()).toBe('person'); + }); + + it('should set type class to initials', () => { + component.type = 'initials'; + component.name = 'Alice'; + fixture.detectChanges(); + expect(avatarEl().classList).toContain('zv-avatar--type-initials'); + }); + }); + + describe('type: image', () => { + it('should render an img element with the provided URL', () => { + component.type = 'image'; + component.image = 'https://example.com/photo.jpg'; + component.name = 'Test'; + fixture.detectChanges(); + const img = fixture.debugElement.query(By.css('.zv-avatar__image')); + expect(img).toBeTruthy(); + expect(img.nativeElement.getAttribute('src')).toBe('https://example.com/photo.jpg'); + }); + + it('should set alt text to name', () => { + component.type = 'image'; + component.image = 'https://example.com/photo.jpg'; + component.name = 'John Doe'; + fixture.detectChanges(); + const img = fixture.debugElement.query(By.css('.zv-avatar__image')); + expect(img.nativeElement.getAttribute('alt')).toBe('John Doe'); + }); + + it('should set alt to empty string when no name', () => { + component.type = 'image'; + component.image = 'https://example.com/photo.jpg'; + component.name = ''; + fixture.detectChanges(); + const img = fixture.debugElement.query(By.css('.zv-avatar__image')); + expect(img.nativeElement.getAttribute('alt')).toBe(''); + }); + + it('should fall back to initials on image error when name is set', () => { + component.type = 'image'; + component.image = 'https://example.com/bad.jpg'; + component.name = 'Alice'; + fixture.detectChanges(); + + const img = fixture.debugElement.query(By.css('.zv-avatar__image')); + img.nativeElement.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + const span = fixture.debugElement.query(By.css('.zv-avatar__initials')); + expect(span).toBeTruthy(); + expect(span.nativeElement.textContent.trim()).toBe('A'); + expect(avatarEl().classList).toContain('zv-avatar--type-initials'); + }); + + it('should fall back to person icon on image error when no name', () => { + component.type = 'image'; + component.image = 'https://example.com/bad.jpg'; + component.name = ''; + fixture.detectChanges(); + + const img = fixture.debugElement.query(By.css('.zv-avatar__image')); + img.nativeElement.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + const icon = fixture.debugElement.query(By.css('mat-icon')); + expect(icon).toBeTruthy(); + expect(icon.nativeElement.textContent.trim()).toBe('person'); + expect(avatarEl().classList).toContain('zv-avatar--type-avatar'); + }); + + it('should show fallback immediately when image URL is empty', () => { + component.type = 'image'; + component.image = ''; + component.name = 'Alice'; + fixture.detectChanges(); + + const img = fixture.debugElement.query(By.css('.zv-avatar__image')); + expect(img).toBeFalsy(); + const span = fixture.debugElement.query(By.css('.zv-avatar__initials')); + expect(span).toBeTruthy(); + expect(span.nativeElement.textContent.trim()).toBe('A'); + }); + + it('should show person icon when image URL and name are both empty', () => { + component.type = 'image'; + component.image = ''; + component.name = ''; + fixture.detectChanges(); + + const icon = fixture.debugElement.query(By.css('mat-icon')); + expect(icon).toBeTruthy(); + expect(icon.nativeElement.textContent.trim()).toBe('person'); + }); + + it('should reset error state when image URL changes', () => { + component.type = 'image'; + component.image = 'https://example.com/bad.jpg'; + component.name = 'Alice'; + fixture.detectChanges(); + + // Trigger error + const img = fixture.debugElement.query(By.css('.zv-avatar__image')); + img.nativeElement.dispatchEvent(new Event('error')); + fixture.detectChanges(); + + // Should be showing initials fallback + expect(fixture.debugElement.query(By.css('.zv-avatar__initials'))).toBeTruthy(); + + // Change image URL + component.image = 'https://example.com/good.jpg'; + fixture.detectChanges(); + + // Should be showing image again (error reset) + const newImg = fixture.debugElement.query(By.css('.zv-avatar__image')); + expect(newImg).toBeTruthy(); + expect(newImg.nativeElement.getAttribute('src')).toBe('https://example.com/good.jpg'); + }); + }); + + describe('sizes', () => { + it('should apply small size class', () => { + component.size = 'small'; + fixture.detectChanges(); + expect(avatarEl().classList).toContain('zv-avatar--small'); + }); + + it('should apply medium size class', () => { + component.size = 'medium'; + fixture.detectChanges(); + expect(avatarEl().classList).toContain('zv-avatar--medium'); + }); + + it('should apply large size class', () => { + component.size = 'large'; + fixture.detectChanges(); + expect(avatarEl().classList).toContain('zv-avatar--large'); + }); + }); + + describe('variants', () => { + it('should apply round variant class', () => { + component.variant = 'round'; + fixture.detectChanges(); + expect(avatarEl().classList).toContain('zv-avatar--round'); + }); + + it('should apply square variant class', () => { + component.variant = 'square'; + fixture.detectChanges(); + expect(avatarEl().classList).toContain('zv-avatar--square'); + }); + }); + + describe('accessibility', () => { + it('should use name as aria-label when provided', () => { + component.name = 'John Doe'; + fixture.detectChanges(); + expect(avatarEl().getAttribute('aria-label')).toBe('John Doe'); + }); + + it('should fall back to "Avatar" when name is empty', () => { + component.name = ''; + fixture.detectChanges(); + expect(avatarEl().getAttribute('aria-label')).toBe('Avatar'); + }); + }); +}); diff --git a/projects/components/avatar/src/avatar.component.ts b/projects/components/avatar/src/avatar.component.ts new file mode 100644 index 00000000..9063071d --- /dev/null +++ b/projects/components/avatar/src/avatar.component.ts @@ -0,0 +1,56 @@ +import { ChangeDetectionStrategy, Component, computed, input, linkedSignal, ViewEncapsulation } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; + +@Component({ + selector: 'zv-avatar', + templateUrl: './avatar.component.html', + styleUrls: ['./avatar.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + imports: [MatIconModule], + host: { + '[class]': 'hostClasses()', + '[attr.role]': '"img"', + '[attr.aria-label]': 'name() || "Avatar"', + }, +}) +export class ZvAvatar { + public readonly size = input<'small' | 'medium' | 'large'>('medium'); + public readonly type = input<'initials' | 'avatar' | 'image'>('avatar'); + public readonly name = input(''); + public readonly image = input(''); + public readonly initialsAmount = input<1 | 2>(1); + public readonly variant = input<'round' | 'square'>('round'); + + public readonly imageError = linkedSignal(() => { + this.image(); + return false; + }); + + public readonly displayType = computed(() => { + const type = this.type(); + if (type === 'initials' && !this.name()?.trim()) { + return 'avatar'; + } + if (type === 'image' && (!this.image() || this.imageError())) { + return this.name()?.trim() ? 'initials' : 'avatar'; + } + return type; + }); + + public readonly initials = computed(() => { + const name = this.name()?.trim(); + if (!name) return ''; + const words = name.split(/\s+/); + if (this.initialsAmount() === 2 && words.length >= 2) { + return (words[0][0] + words[words.length - 1][0]).toUpperCase(); + } + return words[0][0].toUpperCase(); + }); + + public readonly hostClasses = computed(() => { + const classes = ['zv-avatar', `zv-avatar--${this.size()}`, `zv-avatar--${this.variant()}`]; + classes.push(`zv-avatar--type-${this.displayType()}`); + return classes.join(' '); + }); +} diff --git a/projects/zvoove-components-demo/src/app/app.component.html b/projects/zvoove-components-demo/src/app/app.component.html index 383ff506..592f56e4 100644 --- a/projects/zvoove-components-demo/src/app/app.component.html +++ b/projects/zvoove-components-demo/src/app/app.component.html @@ -36,6 +36,7 @@
Components
Action Button + Avatar Block Ui Button Card diff --git a/projects/zvoove-components-demo/src/app/app.config.ts b/projects/zvoove-components-demo/src/app/app.config.ts index 70a471fd..5a0e68d7 100644 --- a/projects/zvoove-components-demo/src/app/app.config.ts +++ b/projects/zvoove-components-demo/src/app/app.config.ts @@ -27,6 +27,10 @@ export const appConfig: ApplicationConfig = { { provide: LOCALE_ID, useValue: getUsersLocale(['en', 'de'], 'en-GB') }, provideHttpClient(withInterceptorsFromDi(), withFetch()), provideRouter([ + { + path: 'avatar', + loadComponent: () => import('./avatar-demo/avatar-demo.page').then((m) => m.AvatarDemoPage), + }, { path: 'action-button', loadComponent: () => import('./action-button-demo/action-button-demo.component').then((c) => c.ActionButtonDemoComponent), diff --git a/projects/zvoove-components-demo/src/app/avatar-demo/avatar-demo.page.html b/projects/zvoove-components-demo/src/app/avatar-demo/avatar-demo.page.html new file mode 100644 index 00000000..34e47b64 --- /dev/null +++ b/projects/zvoove-components-demo/src/app/avatar-demo/avatar-demo.page.html @@ -0,0 +1,111 @@ + + + + + + + avatar + initials + image + + type + + + + + small + medium + large + + size + + + + + round + square + + variant + + + + + 1 + 2 + + initialsAmount + + + + + name + + + + + image + + + + + + + + + + + + + + + + Fallback Demo + + + + + + + + + + + + + + + + + + + + + + + + Imports + + +

Add the following to your imports, where you want to use the zv-avatar:

+ +
+
+
+
diff --git a/projects/zvoove-components-demo/src/app/avatar-demo/avatar-demo.page.ts b/projects/zvoove-components-demo/src/app/avatar-demo/avatar-demo.page.ts new file mode 100644 index 00000000..a0b42dc5 --- /dev/null +++ b/projects/zvoove-components-demo/src/app/avatar-demo/avatar-demo.page.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatCardModule } from '@angular/material/card'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { ZvAvatar } from '@zvoove/components/avatar'; +import { allSharedImports } from '../common/shared-imports'; + +@Component({ + selector: 'app-avatar-demo', + templateUrl: './avatar-demo.page.html', + styles: [ + ` + :host { + display: flex; + flex-direction: column; + gap: 1em; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [allSharedImports, ZvAvatar, MatCardModule, FormsModule, MatInputModule, MatSelectModule], +}) +export class AvatarDemoPage { + type: 'initials' | 'avatar' | 'image' = 'avatar'; + size: 'small' | 'medium' | 'large' = 'medium'; + variant: 'round' | 'square' = 'round'; + initialsAmount: 1 | 2 = 1; + name = 'John Doe'; + image = 'https://i.pravatar.cc/150?img=3'; + + importsCode = ` +import { ZvAvatar } from '@zvoove/components/avatar'; + +// ... +imports: [ + ZvAvatar, +], + `; +}