feat(shared): add Avatar primitive — sizes 26/28/40/48, stacked, overflow chip (§5)

Refs #855
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-16 19:03:07 +02:00
parent 9ae84828ea
commit fe8868d143
2 changed files with 199 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import { avatarFor } from '$lib/shared/avatar';
type AvatarSize = 26 | 28 | 40 | 48;
interface Props {
/** Person name — plain string, never a domain entity */
name?: string;
/** Visual size in px */
size?: AvatarSize;
/** Stacked variant: negative margin + ring-surface ring */
stacked?: boolean;
/**
* a11y mode:
* - true (default when name is adjacent in layout) → aria-hidden
* - false → role="img" + aria-label=name
*/
decorative?: boolean;
/**
* Overflow chip mode — renders "+N" instead of initials.
* When set, name/initials are ignored.
*/
overflow?: number;
/**
* Placeholder mode — dashed border empty circle (for ContributorStack empty state).
*/
placeholder?: boolean;
}
let {
name = '',
size = 40,
stacked = false,
decorative = true,
overflow,
placeholder = false
}: Props = $props();
const avatar = $derived(avatarFor(name));
const sizeClass: Record<AvatarSize, string> = {
26: 'h-[26px] w-[26px] text-[10px]',
28: 'h-[28px] w-[28px] text-[11px]',
40: 'h-[40px] w-[40px] text-[14px]',
48: 'h-[48px] w-[48px] text-[16px]'
};
const classes = $derived(
[
'inline-flex shrink-0 items-center justify-center rounded-full font-sans font-bold text-white transition-colors',
sizeClass[size] ?? sizeClass[40],
stacked ? '-ml-1.5 ring-2 ring-surface dark:ring-surface' : '',
placeholder ? 'border border-dashed border-line bg-transparent text-transparent' : ''
]
.filter(Boolean)
.join(' ')
);
</script>
{#if overflow !== undefined}
<!-- Overflow "+N" chip -->
<span
data-testid="avatar"
class="{classes} bg-muted text-ink-3"
role="img"
aria-label="Weitere Personen"
>
+{overflow}
</span>
{:else if placeholder}
<!-- Empty placeholder (dashed border, no background) -->
<span data-testid="avatar" class={classes} role="img" aria-label="Noch niemand angefangen"></span>
{:else if decorative}
<!-- Decorative: name is adjacent in layout — aria-hidden="true" -->
<span
data-testid="avatar"
class={classes}
style="background-color: {avatar.bg}"
aria-hidden="true"
>
{avatar.initials}
</span>
{:else}
<!-- Standalone: expose name to screen readers via role="img" + aria-label -->
<span
data-testid="avatar"
class={classes}
style="background-color: {avatar.bg}"
role="img"
aria-label={name}
>
{avatar.initials}
</span>
{/if}

View File

@@ -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/);
});
});