From 52466380144f1426f4882f5894e0e370474aa60e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 22:28:00 +0200 Subject: [PATCH] fix(dashboard): fix ContributorStack each-block key and add accessible avatar labels - Replace (actor.name ?? actor.initials + i) with (actor.initials + '-' + actor.color) to fix operator-precedence bug that made keys order-dependent when name is null - Add role="img" + aria-label={actor.name ?? actor.initials} so screen readers and touch users can access contributor names Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/ContributorStack.svelte | 6 ++- .../ContributorStack.svelte.spec.ts | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/components/ContributorStack.svelte.spec.ts diff --git a/frontend/src/lib/components/ContributorStack.svelte b/frontend/src/lib/components/ContributorStack.svelte index 8e453284..fc627627 100644 --- a/frontend/src/lib/components/ContributorStack.svelte +++ b/frontend/src/lib/components/ContributorStack.svelte @@ -20,11 +20,13 @@ const safeContributors = $derived(contributors ?? []); > {:else} - {#each safeContributors as actor, i (actor.name ?? actor.initials + i)} + {#each safeContributors as actor, i (actor.initials + '-' + actor.color)} {actor.initials} diff --git a/frontend/src/lib/components/ContributorStack.svelte.spec.ts b/frontend/src/lib/components/ContributorStack.svelte.spec.ts new file mode 100644 index 00000000..877167cd --- /dev/null +++ b/frontend/src/lib/components/ContributorStack.svelte.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ContributorStack from './ContributorStack.svelte'; +import type { components } from '$lib/generated/api'; + +type ActivityActorDTO = components['schemas']['ActivityActorDTO']; + +afterEach(() => cleanup()); + +const makeActor = (overrides: Partial = {}): ActivityActorDTO => ({ + initials: 'MR', + color: '#7a4f9a', + name: 'Max Raddatz', + ...overrides +}); + +describe('ContributorStack', () => { + it('contributor avatar is announced by screen readers with actor name', async () => { + const actor = makeActor({ name: 'Anna Meier', initials: 'AM' }); + render(ContributorStack, { contributors: [actor], hasMore: false }); + await expect.element(page.getByRole('img', { name: 'Anna Meier' })).toBeInTheDocument(); + }); + + it('falls back to initials as accessible name when actor name is null', async () => { + const actor = makeActor({ name: undefined, initials: 'AM' }); + render(ContributorStack, { contributors: [actor], hasMore: false }); + await expect.element(page.getByRole('img', { name: 'AM' })).toBeInTheDocument(); + }); + + it('renders two avatars without crashing when actors have identical initials', async () => { + const actors = [ + makeActor({ name: undefined, initials: 'AM', color: '#aa0000' }), + makeActor({ name: undefined, initials: 'AM', color: '#0000bb' }) + ]; + render(ContributorStack, { contributors: actors, hasMore: false }); + await expect.element(page.getByText('AM').first()).toBeInTheDocument(); + }); + + it('renders overflow indicator when hasMore is true', async () => { + render(ContributorStack, { contributors: [makeActor()], hasMore: true }); + await expect.element(page.getByText('…')).toBeInTheDocument(); + }); + + it('renders empty placeholder when no contributors', async () => { + render(ContributorStack, { contributors: [], hasMore: false }); + await expect.element(page.getByTitle('Noch niemand angefangen')).toBeInTheDocument(); + }); +});