refactor(dashboard): ReaderPersonChips → grid layout with mint-pill doc count (TDD, #483)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,37 +27,34 @@ interface Props {
|
||||
const { persons }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_reader_person_chips_heading()}
|
||||
</h2>
|
||||
<section aria-label={m.dashboard_reader_person_chips_heading()}>
|
||||
{#if persons.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_no_persons()}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="grid grid-cols-2 gap-1.5 sm:grid-cols-4">
|
||||
{#each persons as p (p.id)}
|
||||
<a
|
||||
href="/persons/{p.id}"
|
||||
class="flex min-h-[44px] items-center gap-2 rounded-sm border border-line bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
class="flex min-h-[44px] flex-col items-center gap-1.5 rounded-sm border border-line bg-surface p-2.5 text-center no-underline transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||
class="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full text-[11px] font-black text-white shadow-sm sm:h-9 sm:w-9 dark:shadow-none dark:ring-1 dark:ring-white/10"
|
||||
style="background-color: {personAvatarColor(p.id ?? '')}"
|
||||
>
|
||||
{getInitials(p.displayName ?? p.lastName ?? '')}
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="text-ink-1 truncate font-serif text-sm">{p.displayName ?? p.lastName}</span>
|
||||
<span class="font-sans text-xs text-ink-3"
|
||||
>{p.documentCount ?? 0} {m.dashboard_reader_doc_count_suffix()}</span
|
||||
>
|
||||
<span class="truncate font-serif text-sm text-ink">{p.displayName ?? p.lastName}</span>
|
||||
<span
|
||||
class="rounded-full bg-mint-soft px-1.5 py-px text-[11px] font-bold text-brand-navy dark:bg-mint-soft dark:text-brand-mint"
|
||||
>
|
||||
{p.documentCount ?? 0}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-flex min-h-[44px] items-center self-end rounded-sm font-sans text-sm text-brand-navy underline hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
class="mt-1 flex min-h-[44px] items-center justify-end text-right text-xs font-semibold text-link-quiet no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
|
||||
>{m.dashboard_reader_all_persons()}</a
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -32,7 +32,7 @@ const person2: PersonSummaryDTO = {
|
||||
};
|
||||
|
||||
describe('ReaderPersonChips', () => {
|
||||
it('renders a chip for each person with correct href', async () => {
|
||||
it('renders a card for each person with correct href', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1, person2] });
|
||||
const link1 = page.getByRole('link', { name: /Anna Müller/ });
|
||||
await expect
|
||||
@@ -44,12 +44,44 @@ describe('ReaderPersonChips', () => {
|
||||
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002');
|
||||
});
|
||||
|
||||
it('shows document count in each chip', async () => {
|
||||
it('person card has min-h-[44px] touch target', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1] });
|
||||
const chip = page.getByRole('link', { name: /Anna Müller/ });
|
||||
await expect.element(chip).toBeInTheDocument();
|
||||
const text = ((await chip.element()) as HTMLElement).textContent;
|
||||
expect(text).toContain('23');
|
||||
const link = page.getByRole('link', { name: /Anna Müller/ });
|
||||
const cls = ((await link.element()) as HTMLElement).className;
|
||||
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||
});
|
||||
|
||||
it('doc count renders as mint pill with bg-mint-soft', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1] });
|
||||
const link = page.getByRole('link', { name: /Anna Müller/ });
|
||||
const el = (await link.element()) as HTMLElement;
|
||||
const pill = el.querySelector('[class*="bg-mint-soft"]');
|
||||
expect(pill).not.toBeNull();
|
||||
expect(pill!.textContent).toContain('23');
|
||||
});
|
||||
|
||||
it('doc count pill has rounded-full class', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1] });
|
||||
const link = page.getByRole('link', { name: /Anna Müller/ });
|
||||
const el = (await link.element()) as HTMLElement;
|
||||
const pill = el.querySelector('[class*="rounded-full"]');
|
||||
expect(pill).not.toBeNull();
|
||||
});
|
||||
|
||||
it('person grid uses grid layout', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1, person2] });
|
||||
const section = page.getByRole('region');
|
||||
const el = (await section.element()) as HTMLElement;
|
||||
const grid = el.querySelector('[class*="grid"]');
|
||||
expect(grid).not.toBeNull();
|
||||
});
|
||||
|
||||
it('wrapper is a section with aria-label', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1] });
|
||||
const section = page.getByRole('region');
|
||||
await expect.element(section).toBeInTheDocument();
|
||||
const label = ((await section.element()) as HTMLElement).getAttribute('aria-label');
|
||||
expect(label).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders an "Alle Personen" link to /persons', async () => {
|
||||
@@ -58,6 +90,13 @@ describe('ReaderPersonChips', () => {
|
||||
await expect.element(allLink).toHaveAttribute('href', '/persons');
|
||||
});
|
||||
|
||||
it('"Alle Personen" link has text-link-quiet class', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1] });
|
||||
const allLink = page.getByRole('link', { name: /Alle Personen/i });
|
||||
const cls = ((await allLink.element()) as HTMLElement).className;
|
||||
expect(cls).toMatch(/text-link-quiet/);
|
||||
});
|
||||
|
||||
it('exposes a focus-visible ring on the "Alle Personen" link', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1] });
|
||||
const allLink = page.getByRole('link', { name: /Alle Personen/i });
|
||||
@@ -73,7 +112,13 @@ describe('ReaderPersonChips', () => {
|
||||
expect(cls).toMatch(/min-h-\[44px\]/);
|
||||
});
|
||||
|
||||
it('renders empty state without chips when persons array is empty', async () => {
|
||||
it('does not render h2 heading', async () => {
|
||||
render(ReaderPersonChips, { persons: [person1] });
|
||||
const heading = page.getByRole('heading', { level: 2 });
|
||||
await expect.element(heading).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state without person cards when persons array is empty', async () => {
|
||||
render(ReaderPersonChips, { persons: [] });
|
||||
const chips = page.getByRole('link', { name: /Müller|Schmidt/ });
|
||||
await expect.element(chips).not.toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user