From fe8868d1435d36818c2f487a08476d105dad9dd4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 16 Jun 2026 19:03:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(shared):=20add=20Avatar=20primitive=20?= =?UTF-8?q?=E2=80=94=20sizes=2026/28/40/48,=20stacked,=20overflow=20chip?= =?UTF-8?q?=20(=C2=A75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #855 Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/shared/primitives/Avatar.svelte | 94 ++++++++++++++++ .../shared/primitives/Avatar.svelte.spec.ts | 105 ++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 frontend/src/lib/shared/primitives/Avatar.svelte create mode 100644 frontend/src/lib/shared/primitives/Avatar.svelte.spec.ts diff --git a/frontend/src/lib/shared/primitives/Avatar.svelte b/frontend/src/lib/shared/primitives/Avatar.svelte new file mode 100644 index 00000000..a7f25870 --- /dev/null +++ b/frontend/src/lib/shared/primitives/Avatar.svelte @@ -0,0 +1,94 @@ + + +{#if overflow !== undefined} + + + +{overflow} + +{:else if placeholder} + + +{:else if decorative} + + +{:else} + + + {avatar.initials} + +{/if} diff --git a/frontend/src/lib/shared/primitives/Avatar.svelte.spec.ts b/frontend/src/lib/shared/primitives/Avatar.svelte.spec.ts new file mode 100644 index 00000000..243db6bc --- /dev/null +++ b/frontend/src/lib/shared/primitives/Avatar.svelte.spec.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import Avatar from './Avatar.svelte'; + +afterEach(() => cleanup()); + +function getAvatar(): HTMLElement | null { + return document.querySelector('[data-testid="avatar"]'); +} + +describe('Avatar', () => { + it('renders initials from name', async () => { + render(Avatar, { name: 'Anna Raddatz' }); + await expect.element(page.getByText('AR')).toBeInTheDocument(); + }); + + it('applies size-26 class for size=26', async () => { + render(Avatar, { name: 'Anna Raddatz', size: 26 }); + const el = getAvatar(); + expect(el).not.toBeNull(); + expect(el!.className).toMatch(/h-\[26px\]/); + expect(el!.className).toMatch(/w-\[26px\]/); + }); + + it('applies size-28 class for size=28', async () => { + render(Avatar, { name: 'Karl Müller', size: 28 }); + const el = getAvatar(); + expect(el).not.toBeNull(); + expect(el!.className).toMatch(/h-\[28px\]/); + expect(el!.className).toMatch(/w-\[28px\]/); + }); + + it('applies size-40 class for size=40', async () => { + render(Avatar, { name: 'Karl Müller', size: 40 }); + const el = getAvatar(); + expect(el).not.toBeNull(); + expect(el!.className).toMatch(/h-\[40px\]/); + expect(el!.className).toMatch(/w-\[40px\]/); + }); + + it('applies size-48 class for size=48', async () => { + render(Avatar, { name: 'Herbert Cram', size: 48 }); + const el = getAvatar(); + expect(el).not.toBeNull(); + expect(el!.className).toMatch(/h-\[48px\]/); + expect(el!.className).toMatch(/w-\[48px\]/); + }); + + it('stacked variant adds -ml-1.5 and ring-2 ring-surface', async () => { + render(Avatar, { name: 'Anna Raddatz', size: 26, stacked: true }); + const el = getAvatar(); + expect(el).not.toBeNull(); + expect(el!.className).toMatch(/-ml-1\.5/); + expect(el!.className).toMatch(/ring-2/); + expect(el!.className).toMatch(/ring-surface/); + }); + + it('non-stacked variant does not have -ml-1.5', async () => { + render(Avatar, { name: 'Anna Raddatz', size: 26, stacked: false }); + const el = getAvatar(); + expect(el).not.toBeNull(); + expect(el!.className).not.toMatch(/-ml-1\.5/); + }); + + it('decorative mode: aria-hidden=true when decorative prop is true', async () => { + render(Avatar, { name: 'Anna Raddatz', decorative: true }); + const el = getAvatar(); + expect(el).not.toBeNull(); + expect(el!.getAttribute('aria-hidden')).toBe('true'); + }); + + it('standalone mode: role=img and aria-label=name when decorative is false', async () => { + render(Avatar, { name: 'Herbert Cram', decorative: false }); + await expect.element(page.getByRole('img', { name: 'Herbert Cram' })).toBeInTheDocument(); + }); + + it('renders overflow +N chip when overflow prop is set', async () => { + render(Avatar, { overflow: 3 }); + await expect.element(page.getByText('+3')).toBeInTheDocument(); + }); + + it('renders placeholder (dashed border) when placeholder prop is true', async () => { + render(Avatar, { placeholder: true, size: 26 }); + const el = getAvatar(); + expect(el).not.toBeNull(); + expect(el!.className).toMatch(/border-dashed/); + }); + + it('has rounded-full class', async () => { + render(Avatar, { name: 'Karl Müller', size: 40 }); + const el = getAvatar(); + expect(el).not.toBeNull(); + expect(el!.className).toMatch(/rounded-full/); + }); + + it('stacked variant carries ring classes (dark mode edge on dark surface)', async () => { + render(Avatar, { name: 'Karl Müller', size: 26, stacked: true }); + const el = getAvatar(); + expect(el).not.toBeNull(); + // ring-2 ring-surface keeps avatar edges visible on dark surface (#011526) + expect(el!.className).toMatch(/ring/); + }); +});