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:
94
frontend/src/lib/shared/primitives/Avatar.svelte
Normal file
94
frontend/src/lib/shared/primitives/Avatar.svelte
Normal 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}
|
||||||
105
frontend/src/lib/shared/primitives/Avatar.svelte.spec.ts
Normal file
105
frontend/src/lib/shared/primitives/Avatar.svelte.spec.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user