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