Testing
Target any bpdm/ui component in your tests — test ids, forwarded attributes, and roles.
This applies to every bpdm/ui component. How you target one in a test depends on the kind of component:
- Simple components (Button, Input, Textarea, Badge, Switch, …) wrap a single native
element and forward extra attributes — so a
data-testidlands directly on it. - Composed components (Select, Multi-select, Tree Select, Dialog, …) render a small tree and don't yet forward arbitrary attributes onto their root — target them by role, text, or a wrapper you control.
Simple components — data-testid
These forward data-testid (and id, aria-*, any data-*) straight to the
element.
import { Button } from '@bpdm/ui/button';
import { Input } from '@bpdm/ui/input';
<Input data-testid="email" placeholder="you@company.com" />
<Button data-testid="save">Save</Button>import { render, screen } from '@testing-library/react';
screen.getByTestId('email');
screen.getByTestId('save');bpdmButton / bpdmInput are directives on native elements, so put the
attribute straight on the element.
<input bpdmInput data-testid="email" placeholder="you@company.com" />
<button bpdmButton data-testid="save">Save</button>import { By } from '@angular/platform-browser';
fixture.debugElement.query(By.css('[data-testid="email"]'));
fixture.debugElement.query(By.css('[data-testid="save"]'));Composed components — role, text, or a wrapper
Components like Select don't forward arbitrary attributes onto their root yet. Prefer accessible queries (role/text), or wrap the component in an element you own.
import { Select } from '@bpdm/ui/select';
const STATUSES = [
{ value: 'todo', label: 'Todo' },
{ value: 'done', label: 'Done' },
];
export function StatusSelect() {
return (
// aria-label gives the trigger an accessible name for getByRole;
// the wrapper test id is an optional fallback
<div data-testid="status">
<Select options={STATUSES} aria-label="Status" placeholder="Set status" />
</div>
);
}import { render, screen } from '@testing-library/react';
import { StatusSelect } from './status-select';
render(<StatusSelect />);
// 1) Accessible query (recommended) — the trigger is a combobox
screen.getByRole('combobox', { name: /status/i });
// 2) Or the wrapper you control
screen.getByTestId('status');<div data-testid="status">
<bpdm-select [options]="statuses" aria-label="Status" placeholder="Set status" />
</div>import { By } from '@angular/platform-browser';
// query the host element by tag, or the wrapper you control
fixture.debugElement.query(By.css('bpdm-select'));
fixture.debugElement.query(By.css('[data-testid="status"]'));Button
The Button is a native <button> (React) or bpdmButton on a
<button>/<a> (Angular) — the canonical role + accessible name query.
Assert disabled / aria-busy for state, and a variant class if you pin styling.
import { render, screen } from '@testing-library/react';
import { Button } from '@bpdm/ui/button';
render(
<Button variant="destructive" disabled>
Delete
</Button>,
);
// query by role + accessible name (most robust)
const btn = screen.getByRole('button', { name: 'Delete' });
expect(btn).toBeDisabled();
expect(btn).toHaveClass('bg-destructive');
expect(btn).toHaveAttribute('type', 'button'); // safe default — never auto-submits
// a loading button is announced busy
render(<Button loading>Save</Button>);
expect(screen.getByRole('button', { name: /Save/ })).toHaveAttribute('aria-busy', 'true');const el = fixture.nativeElement as HTMLElement;
const btn = el.querySelector('button') as HTMLButtonElement;
expect(btn.textContent).toContain('Delete');
expect(btn.classList.contains('bg-destructive')).toBe(true);
expect(btn.getAttribute('type')).toBe('button'); // safe default — never auto-submitsNumber Input
The Number Input is a role="group" wrapping a textbox and two labelled stepper
buttons (Increase / Decrease) — so drive it entirely by role. No test ids needed.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(<NumberInput defaultValue="8" max="10" />);
// the field is a textbox; the steppers are labelled buttons
const field = screen.getByRole('textbox') as HTMLInputElement;
await userEvent.click(screen.getByRole('button', { name: 'Increase' }));
expect(field.value).toBe('9');
// at the bound the stepper is disabled
await userEvent.click(screen.getByRole('button', { name: 'Increase' })); // → 10
expect(screen.getByRole('button', { name: 'Increase' })).toBeDisabled();const el = fixture.nativeElement as HTMLElement;
const field = el.querySelector('input') as HTMLInputElement;
el.querySelector<HTMLButtonElement>('button[aria-label="Increase"]')!.click();
fixture.detectChanges();
expect(field.value).toBe('9');
// the button carries `disabled` once the value hits `max`Money Input
The Money Input renders a textbox next to a decorative (aria-hidden) currency symbol.
Query the field by role. In React extra props spread onto the <input>, so data-testid,
name, and aria-* land directly on it; in Angular query by role or a wrapper you own.
import { render, screen } from '@testing-library/react';
render(<MoneyInput currency="USD" defaultValue="1234.5" data-testid="price" />);
// the field is a textbox; data-testid / name / aria-* land on the input
const field = screen.getByRole('textbox') as HTMLInputElement;
expect(field).toBe(screen.getByTestId('price'));
expect(field.value).toBe('1,234.5'); // grouped display at restconst el = fixture.nativeElement as HTMLElement;
const field = el.querySelector('input') as HTMLInputElement;
expect(field.value).toBe('1,234.5');
// name / aria-label / aria-describedby are forwarded onto the input too
el.querySelector('input[name="price"]');Password Input
The Password Input pairs an <input> with a labelled reveal button (Show password /
Hide password) and a live strength label. A type="password" field exposes no textbox
role, so target it by data-testid — in React props spread onto the input, so it lands there;
in Angular query the input element. Drive the toggle by role.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(<PasswordInput data-testid="pw" />);
const field = screen.getByTestId('pw') as HTMLInputElement;
expect(field).toHaveAttribute('type', 'password');
// reveal via the labelled toggle
await userEvent.click(screen.getByRole('button', { name: 'Show password' }));
expect(field).toHaveAttribute('type', 'text');
// the strength label is linked to the field via aria-describedby
await userEvent.type(field, 'Abcd1234!xyz');
const meterId = field.getAttribute('aria-describedby')!;
expect(document.getElementById(meterId)).toHaveTextContent('Strong');const el = fixture.nativeElement as HTMLElement;
const field = el.querySelector('input') as HTMLInputElement;
expect(field.type).toBe('password');
el.querySelector<HTMLButtonElement>('button[aria-label="Show password"]')!.click();
fixture.detectChanges();
expect(field.type).toBe('text');
// the strength label is exposed via aria-live and aria-describedby
el.querySelector('[aria-live="polite"]');OTP Input
The OTP Input is a role="group" (named by aria-label) wrapping one labelled <input> per
cell (Character N of M). Query the group by role/name and the cells with getAllByRole('textbox')
— no test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(<InputOtp length={6} integerOnly aria-label="Verification code" />);
screen.getByRole('group', { name: 'Verification code' });
const cells = screen.getAllByRole('textbox'); // one per character
expect(cells).toHaveLength(6);
// typing auto-advances; paste fills every cell from any box
await userEvent.type(cells[0], '1');
expect(cells[1]).toHaveFocus();
cells[0].focus();
await userEvent.paste('123456');const el = fixture.nativeElement as HTMLElement;
el.querySelector('[role="group"]'); // named by aria-label
const cells = el.querySelectorAll('input'); // one per character
expect(cells.length).toBe(6);
// dispatch input on a cell, then detectChanges to advance
cells[0].value = '1';
cells[0].dispatchEvent(new Event('input'));
fixture.detectChanges();Secure Field
The Secure Field wraps a text <input> (masked at rest) with labelled Reveal / Hide and
Copy buttons. In React props spread onto the input, so data-testid / name / aria-* land
there; in Angular query the input. Drive the controls by role, and assert the masked vs real
display through the input's value.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(<SecureField data-testid="key" defaultValue="4242424242424242" format="grouped" unmaskedTail={4} />);
const field = screen.getByTestId('key') as HTMLInputElement;
expect(field.value).toBe('•••• •••• •••• 4242'); // masked at rest
await userEvent.click(screen.getByRole('button', { name: 'Reveal' }));
expect(field.value).toBe('4242 4242 4242 4242'); // revealedconst el = fixture.nativeElement as HTMLElement;
const field = el.querySelector('input') as HTMLInputElement;
expect(field.value).toBe('•••• •••• •••• 4242');
el.querySelector<HTMLButtonElement>('button[aria-label="Reveal"]')!.click();
fixture.detectChanges();
expect(field.value).toBe('4242 4242 4242 4242');Float Label
The Float Label renders a real <label> associated with the control it wraps — so the field is
reachable by its label text with getByLabelText, no test ids. It also wires the association
deterministically (a matching id is generated when you don't pass htmlFor), which is worth asserting.
import { render, screen } from '@testing-library/react';
render(
<FloatLabel label="Email">
<Input />
</FloatLabel>,
);
// the label is wired to the control, so query the field by its label text
const field = screen.getByLabelText('Email') as HTMLInputElement;
// association is deterministic even without htmlFor
expect(screen.getByText('Email').getAttribute('for')).toBe(field.id);const el = fixture.nativeElement as HTMLElement;
await fixture.whenStable(); // afterNextRender wires up the id + placeholder
const label = el.querySelector('label') as HTMLLabelElement;
const field = el.querySelector('input') as HTMLInputElement;
expect(label.getAttribute('for')).toBe(field.id); // never danglingCheckbox
The Checkbox is a role="checkbox" with an accessible name — query it by role/name,
toggle by clicking, and assert aria-checked (true / false / mixed for indeterminate).
No test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(<Checkbox aria-label="Accept terms" />);
const cb = screen.getByRole('checkbox', { name: 'Accept terms' });
await userEvent.click(cb);
expect(cb).toHaveAttribute('aria-checked', 'true');
// indeterminate is exposed as aria-checked="mixed"
render(<Checkbox aria-label="Some" checked="indeterminate" onCheckedChange={() => {}} />);
expect(screen.getByRole('checkbox', { name: 'Some' })).toHaveAttribute('aria-checked', 'mixed');const el = fixture.nativeElement as HTMLElement;
const cb = el.querySelector('[role="checkbox"]') as HTMLButtonElement;
cb.click();
fixture.detectChanges();
expect(cb.getAttribute('aria-checked')).toBe('true');
// indeterminate → aria-checked="mixed"; pass aria-label for the accessible nameRadio Group
The Radio Group is a role="radiogroup" of role="radio" items, each with an accessible
name. Query the group and items by role/name, select by clicking (or arrow keys), and assert
aria-checked. No test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(
<RadioGroup aria-label="Plan">
<RadioGroupItem value="free" aria-label="Free" />
<RadioGroupItem value="pro" aria-label="Pro" />
</RadioGroup>,
);
screen.getByRole('radiogroup', { name: 'Plan' });
await userEvent.click(screen.getByRole('radio', { name: 'Pro' }));
expect(screen.getByRole('radio', { name: 'Pro' })).toHaveAttribute('aria-checked', 'true');const el = fixture.nativeElement as HTMLElement;
const radios = el.querySelectorAll('[role="radio"]');
(radios[1] as HTMLButtonElement).click();
fixture.detectChanges();
expect(radios[1].getAttribute('aria-checked')).toBe('true');
// arrow keys move + select; pass aria-label for each item's accessible nameSwitch
The Switch is a role="switch" with an accessible name — query it by role/name, toggle by
clicking, and assert aria-checked. In React data-testid also lands on the control (props are
spread); in Angular query by role.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(<Switch aria-label="Notifications" />);
const s = screen.getByRole('switch', { name: 'Notifications' });
expect(s).toHaveAttribute('aria-checked', 'false');
await userEvent.click(s);
expect(s).toHaveAttribute('aria-checked', 'true');const el = fixture.nativeElement as HTMLElement;
const s = el.querySelector('[role="switch"]') as HTMLButtonElement;
s.click();
fixture.detectChanges();
expect(s.getAttribute('aria-checked')).toBe('true');
// pass aria-label for the accessible nameCalendar
The Calendar wraps its months in a role="group" named Calendar; each month is a
role="grid" named by its caption (e.g. "January 2026") with role="gridcell"s and
role="columnheader" weekday headers. Each day is a button whose accessible name is the
full, locale-formatted date (e.g. "January 15, 2026"), so query days by that name — not by the
bare number. Only the focused day is tabbable (roving tabindex).
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(<Calendar defaultValue={new Date(2026, 0, 15)} />);
screen.getByRole('group', { name: 'Calendar' }); // the wrapper
screen.getByRole('grid', { name: /January 2026/ }); // the month
expect(screen.getAllByRole('columnheader')).toHaveLength(7);
// pick a day by its full accessible name
await userEvent.click(screen.getByRole('button', { name: /January 20, 2026/ }));const el = fixture.nativeElement as HTMLElement;
el.querySelector('[role="grid"]');
el.querySelectorAll('[role="columnheader"]'); // 7
// days live in gridcells; the button's aria-label is the full date
const day = Array.from(el.querySelectorAll('[role="gridcell"] button'))
.find((b) => b.textContent?.trim() === '20') as HTMLButtonElement;
day.click();
fixture.detectChanges();Data Table
The Data Table renders a real semantic <table>, so query it by ARIA role —
no test ids needed. Rows are row, headers are columnheader, cells are cell,
sortable headers are buttons (with aria-sort on the header), selection controls
are checkbox/radio, and pagination sits in a navigation landmark
(<nav aria-label="Pagination">) of labelled buttons. Pass label for an accessible
table name (getByRole('table', { name })).
The example below assumes a sortable + selectable + paginated MembersTable
(the MembersTable from Usage plus sortable on a
column and the selectable / pagination props) — that's what makes the sort,
checkbox, and pagination queries resolve.
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(<MembersTable label="Members" />); // sortable + selectable + paginated
// the table, its rows, and a specific cell — all by role/text
screen.getByRole('table', { name: 'Members' });
screen.getAllByRole('row'); // 1 header row + N body rows
screen.getByRole('cell', { name: 'Sara Kovac' });
// sort by clicking the header button; assert aria-sort on the header
await userEvent.click(screen.getByRole('button', { name: 'Name' }));
expect(screen.getByRole('columnheader', { name: /Name/ })).toHaveAttribute('aria-sort', 'ascending');
// selection controls are checkboxes ([0] is select-all)
await userEvent.click(screen.getAllByRole('checkbox')[1]);
// pagination is a labelled navigation landmark
const pager = screen.getByRole('navigation', { name: /pagination/i });
await userEvent.click(within(pager).getByRole('button', { name: /next page/i }));const el = fixture.nativeElement as HTMLElement;
// query by role/tag — no test ids needed on a semantic table
el.querySelectorAll('tbody tr'); // body rows
el.querySelector('thead th button'); // a sortable header
el.querySelectorAll('[role="checkbox"]'); // [0] = select-all
el.querySelector('input[aria-label="Search"]'); // the search box
el.querySelector('nav[aria-label="Pagination"]'); // the pagination landmarkPick List
The Pick List is two WAI-ARIA **listbox**es (source + target): each list is a
listbox (named by its header), rows are options exposing aria-selected (and
aria-disabled when locked), and the transfer controls are labelled buttons.
Query by role — no test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(<Features />); // a PickList with "Available" / "Enabled" lists
// each list is a named listbox; rows are options
screen.getByRole('listbox', { name: 'Available' });
const option = screen.getByText('Billing').closest('[role="option"]')!;
// select a row, then transfer it with the labelled control
await userEvent.click(option);
await userEvent.click(screen.getByRole('button', { name: 'Move to target' }));
expect(option).toHaveAttribute('aria-selected', 'true'); // in the new list
// a locked item is never selectable
// expect(lockedOption).toHaveAttribute('aria-disabled', 'true');const el = fixture.nativeElement as HTMLElement;
// each list is a listbox; rows are options
const lists = el.querySelectorAll('[role="listbox"]');
const options = lists[0].querySelectorAll('[role="option"]');
// transfer via a labelled button, then assert state
(options[1] as HTMLElement).click();
fixture.detectChanges();
el.querySelector<HTMLButtonElement>('button[aria-label="Move to target"]')!.click();
fixture.detectChanges();
// options carry aria-selected / aria-disabled for assertionsOrder List
The Order List is a WAI-ARIA listbox (named by its header) of option
rows exposing aria-selected, beside a role="group" of labelled reorder
buttons (Move up / to top / down / to bottom, or your messages). Query by
role: select a row, move it with a control by its accessible name, then assert the
new order (each move is also announced through a polite live region). No test ids.
import { useState } from 'react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OrderList } from '@bpdm/ui/order-list';
function Pipeline() {
const [items, setItems] = useState(['Lint', 'Build', 'Deploy']);
return (
<OrderList value={items} onChange={setItems} itemKey={(s) => s} renderItem={(s) => s} header="Pipeline stages" />
);
}
render(<Pipeline />);
// the list is a named listbox; rows are options
const list = screen.getByRole('listbox', { name: 'Pipeline stages' });
const order = () => within(list).getAllByRole('option').map((o) => o.textContent);
expect(order()).toEqual(['Lint', 'Build', 'Deploy']);
// select a row, then move it with the labelled control (default messages)
await userEvent.click(screen.getByText('Deploy'));
await userEvent.click(screen.getByRole('button', { name: 'Move to top' }));
// the order updates (and the move is announced via the polite live region)
expect(order()).toEqual(['Deploy', 'Lint', 'Build']);const el = fixture.nativeElement as HTMLElement;
// the list is a listbox; rows are options
const list = el.querySelector('[role="listbox"]') as HTMLElement;
const order = () =>
Array.from(list.querySelectorAll('[role="option"]')).map((o) => o.textContent?.trim());
expect(order()).toEqual(['Lint', 'Build', 'Deploy']);
// select a row, then move it with the labelled control (default messages)
const rows = list.querySelectorAll<HTMLElement>('[role="option"]');
rows[2].click();
fixture.detectChanges();
el.querySelector<HTMLButtonElement>('button[aria-label="Move to top"]')!.click();
fixture.detectChanges();
expect(order()).toEqual(['Deploy', 'Lint', 'Build']);Status Timeline
The timeline renders a semantic list (named by label), each step is a
listitem, the current step carries aria-current="step", and interactive
steps / custom markers are labelled buttons — so query by role, no test ids. Each
step also carries a visually-hidden status label ("Completed" / "In progress" /
"Not started" / "Failed"), so assert state by text — never the marker colour — and
remember it's overridable via messages.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(<DeployTimeline label="Deploy pipeline" />);
// the list (by its label) and its steps
screen.getByRole('list', { name: 'Deploy pipeline' });
screen.getAllByRole('listitem'); // one per step
// the current step is discoverable via aria-current
const current = screen.getByText('Deploying').closest('li');
expect(current).toHaveAttribute('aria-current', 'step');
// status is announced, not colour-only — assert the sr-only label
expect(current).toHaveTextContent('In progress');
screen.getByText('Completed'); // a complete step's status
// interactive steps / custom markers are labelled buttons
await userEvent.click(screen.getByRole('button', { name: /complete step 2/i }));const el = fixture.nativeElement as HTMLElement;
el.querySelector('[aria-label="Deploy pipeline"]'); // the list
el.querySelectorAll('li'); // steps
const current = el.querySelector('[aria-current="step"]'); // the current step
expect(current?.textContent).toContain('In progress'); // sr-only status, not colour
el.querySelector<HTMLButtonElement>('button[aria-label="Complete step 2"]')?.click();
fixture.detectChanges();Stat Card
The card is a role="group" named by its label (aria-labelledby); the delta
exposes a screen-reader text alternative ("Increased 12.5%" / "Decreased 3.2%" /
"No change"), so query the group by name and assert the delta's accessible label —
never the red/green colour.
import { render, screen } from '@testing-library/react';
import { StatCard } from '@bpdm/ui/stat-card';
render(<StatCard label="Active users" value="8,420" delta={3.1} deltaLabel="vs last week" />);
// the card is a labelled group
screen.getByRole('group', { name: 'Active users' });
// the delta's direction is announced (not colour-only)
screen.getByLabelText('Increased 3.1%');const el = fixture.nativeElement as HTMLElement;
const card = el.querySelector('bpdm-stat-card') as HTMLElement;
expect(card.getAttribute('role')).toBe('group'); // named by label via aria-labelledby
el.querySelector('[aria-label="Increased 3.1%"]'); // delta direction for screen readersAccordion
Each header is a button carrying aria-expanded + aria-controls, and each panel
is a role="region" labelled by its trigger (aria-labelledby) — so drive it entirely
by role, no test ids. Toggle a header and assert aria-expanded; a collapsed panel is
out of the tab order and hidden from assistive tech (React unmounts it, so it's no
longer a queryable region; Angular keeps it in the DOM but marks it inert).
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Accordion, type AccordionItemData } from '@bpdm/ui/accordion';
const items: AccordionItemData[] = [
{ value: 'deploys', title: 'How are deploys triggered?', content: 'Every push to main runs CI.' },
{ value: 'logs', title: 'Where are build logs stored?', content: 'Logs are kept for 30 days.' },
];
render(<Accordion type="single" collapsible items={items} />);
// each header is a button carrying aria-expanded
const trigger = screen.getByRole('button', { name: 'How are deploys triggered?' });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
// open it — the panel becomes a region labelled by its trigger
await userEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('region', { name: 'How are deploys triggered?' })).toBeVisible();
// a collapsed panel is hidden from assistive tech (no region) + out of the tab order
expect(screen.queryByRole('region', { name: 'Where are build logs stored?' })).toBeNull();const el = fixture.nativeElement as HTMLElement;
const triggers = el.querySelectorAll<HTMLButtonElement>('button[aria-controls]');
const panels = el.querySelectorAll<HTMLElement>('[role="region"]');
// each panel is labelled by its trigger
expect(panels[0].getAttribute('aria-labelledby')).toBe(triggers[0].id);
// toggle the first header
expect(triggers[0].getAttribute('aria-expanded')).toBe('false');
triggers[0].click();
fixture.detectChanges();
expect(triggers[0].getAttribute('aria-expanded')).toBe('true');
// a collapsed panel is inert — out of the tab order + hidden from AT
expect(panels[1].hasAttribute('inert')).toBe(true);Card
The Card is compositional and renders plain markup, so target it by role and
text — no test ids. CardTitle is a real heading (getByRole('heading'); its
level follows the React as prop / the Angular tag). A fully-clickable card is a
real <a>/<button> (via asChild in React), so query it with
getByRole('link'). Pin the surface with a variant / hoverable class only if you
rely on it, and assert the footer's divider by its border-t class.
import { render, screen } from '@testing-library/react';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@bpdm/ui/card';
render(
<Card variant="outlined" hoverable asChild>
<a href="/reports">
<CardHeader>
<CardTitle as="h2">Weekly report</CardTitle>
</CardHeader>
<CardContent>Deploys, reviews, and open tasks.</CardContent>
<CardFooter divider>
<span>Footer</span>
</CardFooter>
</a>
</Card>,
);
// the title is a real heading at the level you set
const heading = screen.getByRole('heading', { level: 2, name: 'Weekly report' });
// asChild makes the whole card one link
const link = screen.getByRole('link', { name: /Weekly report/ });
expect(link).toHaveClass('border-border'); // outlined surface
// the divider is a class on the footer
expect(screen.getByText('Footer').closest('div')).toHaveClass('border-t');const el = fixture.nativeElement as HTMLElement;
// the title is a real heading — the tag you chose sets the level
const heading = el.querySelector('h2') as HTMLHeadingElement;
expect(heading.textContent).toContain('Weekly report');
// a clickable card is your own <a>/<button> wrapping the card
el.querySelector('a')!;
// the surface variant lands on the card host element
const card = el.querySelector('bpdm-card') as HTMLElement;
expect(card.classList.contains('border-border')).toBe(true); // outlined
// the footer's divider is a class toggled by [divider]
expect(el.querySelector('[bpdmCardFooter]')!.classList.contains('border-t')).toBe(true);Tabs
The Tabs are a WAI-ARIA tablist: the list is role="tablist", each tab a
role="tab" carrying aria-selected, and the active panel a role="tabpanel" — so
drive it entirely by role, no test ids. Click a tab (or arrow to it) and assert
aria-selected plus the panel that shows. Only the active tab is in the tab order
(roving tabindex), and disabled tabs carry disabled / are skipped by arrow keys.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Tabs, type TabItem } from '@bpdm/ui/tabs';
const items: TabItem[] = [
{ value: 'overview', label: 'Overview', content: 'Key metrics and recent activity.' },
{ value: 'activity', label: 'Activity', content: 'Deploys, comments, and reviews.' },
{ value: 'settings', label: 'Settings', disabled: true, content: 'Members and roles.' },
];
render(<Tabs items={items} defaultValue="overview" ariaLabel="Workspace sections" />);
// the tablist is named; the first tab is selected
screen.getByRole('tablist', { name: 'Workspace sections' });
const overview = screen.getByRole('tab', { name: 'Overview' });
expect(overview).toHaveAttribute('aria-selected', 'true');
screen.getByRole('tabpanel', { name: 'Overview' });
// clicking a tab switches selection + panel
await userEvent.click(screen.getByRole('tab', { name: 'Activity' }));
expect(screen.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
screen.getByRole('tabpanel', { name: 'Activity' });
// arrow keys move between tabs (and skip the disabled one)
overview.focus();
await userEvent.keyboard('{ArrowRight}');
expect(screen.getByRole('tab', { name: 'Activity' })).toHaveFocus();
// a disabled tab is not selectable
expect(screen.getByRole('tab', { name: 'Settings' })).toBeDisabled();const el = fixture.nativeElement as HTMLElement;
const tabs = el.querySelectorAll<HTMLButtonElement>('[role="tab"]');
const panel = () => el.querySelector('[role="tabpanel"]');
// the first tab is selected, its panel is labelled by it
expect(tabs[0].getAttribute('aria-selected')).toBe('true');
expect(panel()!.getAttribute('aria-labelledby')).toBe(tabs[0].id);
// click a tab → selection + panel switch
tabs[1].click();
fixture.detectChanges();
expect(tabs[1].getAttribute('aria-selected')).toBe('true');
// only the active tab is tabbable (roving tabindex); disabled tabs carry `disabled`
expect(tabs[1].getAttribute('tabindex')).toBe('0');
// el.querySelector('[role="tab"][disabled]') for a disabled itemStepper
The Stepper's step list is a role="tablist"; each step is a role="tab"
carrying aria-selected (and aria-current="step" on the active one), wired to its
role="tabpanel". Each step also carries a visually-hidden status ("Step 2 of 3,
Current step"), and with linear a not-yet-reached step is disabled — so query by
role, assert state by text, and drive the flow through the Back / Next buttons. No
test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
render(<LinearOnboarding />); // a linear Stepper with a Back/Next bar
// the step list is a tablist; each step is a tab carrying aria-selected
const steps = screen.getAllByRole('tab');
expect(steps[0]).toHaveAttribute('aria-selected', 'true');
// status is announced, not colour-only — via a visually-hidden per-step label
expect(steps[0]).toHaveTextContent('Step 1 of 3');
expect(steps[0]).toHaveTextContent('Current step');
// with `linear`, a not-yet-reached step is disabled (skipped by keyboard + AT)
expect(steps[2]).toBeDisabled();
// Next (driven by useStepper) advances selection + swaps the panel
await userEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getAllByRole('tab')[1]).toHaveAttribute('aria-selected', 'true');
screen.getByRole('tabpanel'); // the active panelconst el = fixture.nativeElement as HTMLElement;
const steps = () => el.querySelectorAll<HTMLButtonElement>('[role="tab"]');
// the first step is selected; its status is announced (sr-only), not colour-only
expect(steps()[0].getAttribute('aria-selected')).toBe('true');
expect(steps()[0].textContent).toContain('Current step');
// with `linear`, a future step is disabled
expect(steps()[2].disabled).toBe(true);
// drive Next from the #s template ref → selection + panel switch
const next = Array.from(el.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Next')!;
next.click();
fixture.detectChanges();
expect(steps()[1].getAttribute('aria-selected')).toBe('true');
el.querySelector('[role="tabpanel"]'); // the active panelDialog
The Dialog opens from its trigger into a role="dialog" (aria-modal="true")
named by its title (aria-labelledby) — so open it by clicking the trigger, then
query the dialog by role + name. The close button is a labelled button (Close,
or your messages.close), and Esc / a backdrop click closes it. Query by role,
no test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Dialog, DialogClose } from '@bpdm/ui/dialog';
import { Button } from '@bpdm/ui/button';
render(
<Dialog
trigger={<Button>Edit profile</Button>}
title="Edit profile"
footer={<DialogClose asChild><Button>Save</Button></DialogClose>}
>
<p>Body</p>
</Dialog>,
);
// closed at rest — opens when the trigger is clicked
expect(screen.queryByRole('dialog')).toBeNull();
await userEvent.click(screen.getByRole('button', { name: 'Edit profile' }));
// the open dialog is named by its title
const dialog = screen.getByRole('dialog', { name: 'Edit profile' });
// the close button carries an accessible name (messages.close)
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog')).toBeNull();
// Esc also closes it
await userEvent.click(screen.getByRole('button', { name: 'Edit profile' }));
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).toBeNull();const el = fixture.nativeElement as HTMLElement;
// open via the trigger — the panel is portaled to the overlay container (document.body)
el.querySelector<HTMLButtonElement>('button[bpdmDialogTrigger]')!.click();
fixture.detectChanges();
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
expect(dialog.getAttribute('aria-modal')).toBe('true');
// named by its title via aria-labelledby
const titleId = dialog.getAttribute('aria-labelledby')!;
expect(document.getElementById(titleId)!.textContent).toContain('Edit profile');
// the close button is labelled (messages.close); clicking it dismisses
document.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!.click();
fixture.detectChanges();Confirm Dialog
The Confirm Dialog is imperative — a small host triggers confirm({ … }), which
opens a role="dialog" named by its title, then resolves the awaited promise to
true (Confirm) or false (Cancel / Esc / backdrop). Drive it by role: click the
trigger, assert the dialog and its labelled Confirm / Cancel buttons, then
assert the resolved boolean. No test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ConfirmProvider, useConfirm } from '@bpdm/ui/confirm-dialog';
import { Button } from '@bpdm/ui/button';
let lastResult: boolean | undefined;
function DeleteButton() {
const confirm = useConfirm();
return (
<Button
onClick={async () => {
lastResult = await confirm({ title: 'Delete project?', confirmText: 'Delete', destructive: true });
}}
>
Delete project
</Button>
);
}
render(
<ConfirmProvider>
<DeleteButton />
</ConfirmProvider>,
);
// trigger the prompt — it opens a dialog named by its title
await userEvent.click(screen.getByRole('button', { name: 'Delete project' }));
screen.getByRole('dialog', { name: 'Delete project?' });
// clicking Confirm (labelled by confirmText) resolves true
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(lastResult).toBe(true);
// re-open, then Cancel → resolves false
await userEvent.click(screen.getByRole('button', { name: 'Delete project' }));
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(lastResult).toBe(false);import { Component, inject } from '@angular/core';
import { BpdmConfirm, BpdmButton } from '@bpdm/ng';
// a small host injects BpdmConfirm and stores the resolved result
@Component({
selector: 'delete-host',
imports: [BpdmButton],
template: `<button bpdmButton (click)="onDelete()">Delete project</button>`,
})
class DeleteHost {
private readonly confirm = inject(BpdmConfirm);
result?: boolean;
async onDelete() {
this.result = await this.confirm.confirm({ title: 'Delete project?', confirmText: 'Delete', destructive: true });
}
}
const el = fixture.nativeElement as HTMLElement;
// trigger the prompt — the panel is portaled to the overlay container (document.body)
el.querySelector('button')!.click();
fixture.detectChanges();
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
const titleId = dialog.getAttribute('aria-labelledby')!;
expect(document.getElementById(titleId)!.textContent).toContain('Delete project?');
// click Confirm (labelled by confirmText); await the microtask, then result === true
const confirmBtn = Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === 'Delete')!;
confirmBtn.click();
// a Cancel / Esc / backdrop click resolves fixture.componentInstance.result to false insteadDynamic Dialog
The Dynamic Dialog opens imperatively — a host calls dialog.open(content, { title })
(React useDialog() / Angular BpdmDialogService), rendering a role="dialog" named by
its title with your content inside. Drive it by role: open it, assert the dialog and its
content, then close via the close callback. Opening a second one from inside stacks two
dialogs. No test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DialogProvider, useDialog } from '@bpdm/ui/dynamic-dialog';
import { Button } from '@bpdm/ui/button';
function Host() {
const dialog = useDialog();
return (
<Button
onClick={() =>
dialog.open(
({ close }) => (
<div>
<p>Rename your project.</p>
<Button onClick={close}>Save</Button>
</div>
),
{ title: 'Edit project' },
)
}
>
Edit project
</Button>
);
}
render(
<DialogProvider>
<Host />
</DialogProvider>,
);
// nothing open at rest — opens on the imperative call
expect(screen.queryByRole('dialog')).toBeNull();
await userEvent.click(screen.getByRole('button', { name: 'Edit project' }));
// the dialog is named by its title and shows the content
const dialog = screen.getByRole('dialog', { name: 'Edit project' });
screen.getByText('Rename your project.');
// the content's close callback dismisses it
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(screen.queryByRole('dialog')).toBeNull();const el = fixture.nativeElement as HTMLElement;
// a host injects BpdmDialogService and opens a template with { title }
el.querySelector('button')!.click(); // e.g. (click)="dialog.open(formTpl, { title: 'Edit project' })"
fixture.detectChanges();
// the panel is portaled to the overlay container (document.body)
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
expect(dialog.getAttribute('aria-modal')).toBe('true');
const titleId = dialog.getAttribute('aria-labelledby')!;
expect(document.getElementById(titleId)!.textContent).toContain('Edit project');
// the close button (messages.close) or the template's d.close() dismisses it
document.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!.click();
fixture.detectChanges();
// opening a second dialog from inside the first stacks two — assert both are present
// expect(document.querySelectorAll('[role="dialog"]').length).toBe(2);Step Dialog
The Step Dialog is a wizard built on the Dialog — open it from its trigger into
a role="dialog" named by the current step's title, then drive it through the
labelled Next / Back buttons. On the last step the primary button becomes
Finish (or your finishText), whose click fires onComplete / (complete) and
closes the dialog. Query by role, no test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { StepDialog, type StepDialogStep } from '@bpdm/ui/step-dialog';
import { Button } from '@bpdm/ui/button';
const steps: StepDialogStep[] = [
{ title: 'Account', content: <p>Step one</p> },
{ title: 'Profile', content: <p>Step two</p> },
{ title: 'Done', content: <p>Step three</p> },
];
const onComplete = jest.fn();
render(
<StepDialog trigger={<Button>Get started</Button>} title="Set up workspace" steps={steps} onComplete={onComplete} />,
);
// open it — the panel is a dialog named by its title
await userEvent.click(screen.getByRole('button', { name: 'Get started' }));
const dialog = screen.getByRole('dialog', { name: 'Set up workspace' });
// the first step shows; advance with the labelled Next button
screen.getByText('Step one');
await userEvent.click(screen.getByRole('button', { name: 'Next' }));
screen.getByText('Step two');
// Back steps you one back
await userEvent.click(screen.getByRole('button', { name: 'Back' }));
screen.getByText('Step one');
// walk to the last step — the primary button becomes Finish
await userEvent.click(screen.getByRole('button', { name: 'Next' }));
await userEvent.click(screen.getByRole('button', { name: 'Next' }));
// Finish fires onComplete and closes the dialog
await userEvent.click(screen.getByRole('button', { name: 'Finish' }));
expect(onComplete).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('dialog')).toBeNull();const el = fixture.nativeElement as HTMLElement;
// open via the trigger — the panel is portaled to the overlay container (document.body)
el.querySelector<HTMLButtonElement>('button[bpdmStepDialogTrigger]')!.click();
fixture.detectChanges();
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
const titleId = dialog.getAttribute('aria-labelledby')!;
expect(document.getElementById(titleId)!.textContent).toContain('Set up workspace');
// find footer buttons by their text and advance with Next
const byText = (t: string) =>
Array.from(document.querySelectorAll('button')).find((b) => b.textContent?.trim() === t)!;
byText('Next').click();
fixture.detectChanges();
// on the last step the primary button reads Finish; clicking it emits (complete) + closes
byText('Next').click();
fixture.detectChanges();
byText('Finish').click();
fixture.detectChanges();
// assert your (complete) handler ran, e.g. expect(component.onComplete).toHaveBeenCalled();Drawer
The Drawer opens from its trigger into a role="dialog" (aria-modal="true")
named by its title (aria-labelledby) — so open it by clicking the trigger, then
query the panel by role + name and assert its body. The close (X) button is a
labelled button (Close, or your messages.close), and Esc / a backdrop click
also dismiss it. Query by role, no test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Drawer, DrawerClose } from '@bpdm/ui/drawer';
import { Button } from '@bpdm/ui/button';
render(
<Drawer
trigger={<Button>Open settings</Button>}
title="Workspace settings"
footer={<DrawerClose asChild><Button>Save</Button></DrawerClose>}
>
<p>Your workspace preferences live here.</p>
</Drawer>,
);
// closed at rest — opens when the trigger is clicked
expect(screen.queryByRole('dialog')).toBeNull();
await userEvent.click(screen.getByRole('button', { name: 'Open settings' }));
// the open panel is a dialog named by its title, showing the body
const drawer = screen.getByRole('dialog', { name: 'Workspace settings' });
screen.getByText('Your workspace preferences live here.');
// the close (X) button carries an accessible name (messages.close)
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog')).toBeNull();
// Esc also closes it
await userEvent.click(screen.getByRole('button', { name: 'Open settings' }));
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).toBeNull();const el = fixture.nativeElement as HTMLElement;
// open via the trigger — the panel is portaled to the overlay container (document.body)
el.querySelector<HTMLButtonElement>('button[bpdmDrawerTrigger]')!.click();
fixture.detectChanges();
const drawer = document.querySelector('[role="dialog"]') as HTMLElement;
expect(drawer.getAttribute('aria-modal')).toBe('true');
// named by its title via aria-labelledby
const titleId = drawer.getAttribute('aria-labelledby')!;
expect(document.getElementById(titleId)!.textContent).toContain('Workspace settings');
// the close (X) button is labelled (messages.close); clicking it dismisses
document.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!.click();
fixture.detectChanges();Popover
The Popover opens from its trigger into a role="dialog" panel; the trigger
carries aria-expanded (false at rest, true when open) and aria-haspopup="dialog".
Open it by clicking the trigger, assert the content + aria-expanded="true", then
close via a PopoverClose / bpdmPopoverClose button (or Esc) and assert it's gone.
Query by role, no test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Popover, PopoverClose } from '@bpdm/ui/popover';
import { Button } from '@bpdm/ui/button';
render(
<Popover trigger={<Button>Account</Button>}>
<p>Signed in as Aria</p>
<PopoverClose asChild>
<Button>Done</Button>
</PopoverClose>
</Popover>,
);
// closed at rest — the trigger reflects it via aria-expanded
const trigger = screen.getByRole('button', { name: 'Account' });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('dialog')).toBeNull();
// open it — the panel is a dialog and the trigger flips to expanded
await userEvent.click(trigger);
screen.getByRole('dialog');
expect(trigger).toHaveAttribute('aria-expanded', 'true');
screen.getByText('Signed in as Aria');
// a PopoverClose button dismisses it (Esc works too)
await userEvent.click(screen.getByRole('button', { name: 'Done' }));
expect(screen.queryByRole('dialog')).toBeNull();const el = fixture.nativeElement as HTMLElement;
// the trigger reflects the closed state
const trigger = el.querySelector<HTMLButtonElement>('[bpdmPopover]')!;
expect(trigger.getAttribute('aria-expanded')).toBe('false');
// open via the trigger — the panel is portaled to the overlay container (document.body)
trigger.click();
fixture.detectChanges();
const panel = document.querySelector('[role="dialog"]') as HTMLElement;
expect(trigger.getAttribute('aria-expanded')).toBe('true');
expect(panel.textContent).toContain('Signed in as Aria');
// a bpdmPopoverClose button dismisses it (Esc on the trigger works too)
document.querySelector<HTMLButtonElement>('[bpdmPopoverClose]')!.click();
fixture.detectChanges();
// after the close animation the overlay is torn down → no [role="dialog"]Tooltip
The Tooltip shows its content on hover and keyboard focus in a
role="tooltip" bubble, and wires the trigger to it via aria-describedby —
so drive it by role: focus (or hover) the trigger, find the tooltip by role, assert
its text and the trigger's aria-describedby, then Esc / blur to dismiss. No test
ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Tooltip } from '@bpdm/ui/tooltip';
import { Button } from '@bpdm/ui/button';
render(
<Tooltip content="Copy address">
<Button>Copy</Button>
</Tooltip>,
);
// nothing shown at rest
const trigger = screen.getByRole('button', { name: 'Copy' });
expect(screen.queryByRole('tooltip')).toBeNull();
// it opens on keyboard focus (hover works too) — the bubble is a role="tooltip"
await userEvent.tab();
expect(trigger).toHaveFocus();
const tip = await screen.findByRole('tooltip');
expect(tip).toHaveTextContent('Copy address');
// the trigger is described by the tooltip
expect(trigger).toHaveAttribute('aria-describedby');
// Esc (or blur) dismisses it
await userEvent.keyboard('{Escape}');
expect(screen.queryByRole('tooltip')).toBeNull();import { fakeAsync, tick } from '@angular/core/testing';
// host template: <button bpdmButton bpdmTooltip="Copy address">Copy</button>
// wrap the test body in fakeAsync(() => { … }) to advance the open delay
const el = fixture.nativeElement as HTMLElement;
const trigger = el.querySelector('button') as HTMLButtonElement;
// nothing shown at rest — the bubble is portaled to the overlay container (document.body)
expect(document.querySelector('[role="tooltip"]')).toBeNull();
// focus (or mouseenter) opens it after the delay
trigger.dispatchEvent(new Event('focusin'));
tick(200); // default delayDuration
fixture.detectChanges();
const tip = document.querySelector('[role="tooltip"]') as HTMLElement;
expect(tip.textContent).toContain('Copy address');
// the trigger is described by the tooltip
expect(trigger.getAttribute('aria-describedby')).toBe(tip.id);Alert
The Alert renders its message in a role="alert" live region, so query it by
role and assert its text — never the variant colour. A dismissible alert adds a
labelled dismiss button (Dismiss, or your messages.dismiss); it collapses on
click and fires onClose / (closed) once the collapse transition ends, so
dispatch a transitionend (propertyName: 'grid-template-rows') to complete it.
No test ids.
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Alert } from '@bpdm/ui/alert';
const onClose = jest.fn();
const { container } = render(
<Alert variant="success" title="Invite sent" onClose={onClose}>
Mara will get an email with a link to join.
</Alert>,
);
// the message is a live region — query by role, assert the text
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Invite sent');
// the dismiss button is labelled (messages.dismiss, default "Dismiss")
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
// onClose fires after the collapse transition ends
fireEvent.transitionEnd(container.firstChild as Element, { propertyName: 'grid-template-rows' });
expect(onClose).toHaveBeenCalledTimes(1);const host = fixture.nativeElement as HTMLElement; // <bpdm-alert>
// the message is a live region — query by role, assert the text
const alert = host.querySelector('[role="alert"]') as HTMLElement;
expect(alert.textContent).toContain('Invite sent');
// the dismiss button is labelled (messages.dismiss, default "Dismiss")
host.querySelector<HTMLButtonElement>('button[aria-label="Dismiss"]')!.click();
fixture.detectChanges();
// (closed) emits after the collapse transition ends
host.dispatchEvent(Object.assign(new Event('transitionend'), { propertyName: 'grid-template-rows' }));
fixture.detectChanges();
// assert your (closed) handler ran, e.g. expect(component.onClosed).toHaveBeenCalled();Toast
Toast is fired imperatively, so there's nothing to render at the callsite —
mount the toaster (<Toaster /> / <bpdm-toaster />), fire a toast, then query
the live region by role and text. error toasts announce as role="alert",
every other variant as role="status". Dismiss via the labelled close button
(Dismiss, or your messages.dismiss). No test ids.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Toaster, toast } from '@bpdm/ui/toast';
render(<Toaster />);
// fire a toast, then find it by role + text (status for non-error variants)
toast.success('Invite sent');
const status = await screen.findByRole('status');
expect(status).toHaveTextContent('Invite sent');
// the close button is labelled (messages.dismiss, default "Dismiss")
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(screen.queryByText('Invite sent')).not.toBeInTheDocument();// host template: <bpdm-toaster /> — inject the service, fire, then query the DOM
const service = TestBed.inject(BpdmToast);
const host = fixture.nativeElement as HTMLElement;
service.show('Invite sent');
fixture.detectChanges();
// find the live region by role + text (status for non-error variants)
const status = host.querySelector('[role="status"]') as HTMLElement;
expect(status.textContent).toContain('Invite sent');
// the close button is labelled (messages.dismiss, default "Dismiss")
host.querySelector<HTMLButtonElement>('button[aria-label="Dismiss"]')!.click();
fixture.detectChanges();
expect(host.textContent).not.toContain('Invite sent');Avatar
The Avatar falls back to initials from name, so assert the initials by text.
The presence dot is an image (role="img") whose accessible name is the
localised status label (messages, default "Online" / "Busy" / …), so query it
by role + name — never the colour. An AvatarGroup past its max renders a +N
overflow tile whose text comes from messages.more (default "{count} more"), so
assert that label. No test ids.
import { render, screen } from '@testing-library/react';
import { Avatar, AvatarGroup } from '@bpdm/ui/avatar';
render(<Avatar name="Aria Lindqvist" status="busy" />);
// with no image the initials show as text
screen.getByText('AL');
// the status dot is an image with a localised accessible name (not colour-only)
screen.getByRole('img', { name: 'Busy' });
// a group past `max` collapses the rest into a "+N" tile (messages.more)
render(
<AvatarGroup max={2}>
<Avatar name="Aria Lindqvist" />
<Avatar name="Theo Brandt" />
<Avatar name="Lena Cho" />
<Avatar name="Mateo Silva" />
</AvatarGroup>,
);
screen.getByText('+2'); // 4 avatars, 2 shown → "+2"const el = fixture.nativeElement as HTMLElement;
// with no image the initials show as text
expect(el.textContent).toContain('AL');
// the status dot is an image with a localised accessible name (not colour-only)
const dot = el.querySelector('[role="img"]') as HTMLElement;
expect(dot.getAttribute('aria-label')).toBe('Busy');
// a <bpdm-avatar-group [users]="people" [max]="2" /> past its max renders a "+N" tile
// expect(el.textContent).toContain('+2'); // 4 users, 2 shown → "+2" (messages.more)Badge
The Badge is a simple <span> label, so assert its text directly; in React
extra props (data-testid, id, aria-*) spread onto it. A removable badge
adds a labelled remove button (Remove, or your messages.remove) that fires
onRemove / (removed) once the collapse transition ends — so click it and, in
React, dispatch a transitionend (propertyName: 'grid-template-columns') to
complete it. NotificationBadge renders its count as text (capped as 99+), so
assert that text. No test ids needed.
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Badge, NotificationBadge } from '@bpdm/ui/badge';
const onRemove = jest.fn();
const { container } = render(
<Badge variant="neutral" onRemove={onRemove}>
Design
</Badge>,
);
// the label is plain text
screen.getByText('Design');
// the remove button is labelled (messages.remove, default "Remove")
await userEvent.click(screen.getByRole('button', { name: 'Remove' }));
// onRemove fires after the collapse transition ends
fireEvent.transitionEnd(container.firstChild as Element, { propertyName: 'grid-template-columns' });
expect(onRemove).toHaveBeenCalledTimes(1);
// NotificationBadge renders the count as text, capped by max ("99+")
render(<NotificationBadge count={5}><span>Inbox</span></NotificationBadge>);
screen.getByText('5');
render(<NotificationBadge count={128} max={99}><span>Inbox</span></NotificationBadge>);
screen.getByText('99+');const host = fixture.nativeElement as HTMLElement; // <bpdm-badge removable>
// the label is plain text
expect(host.textContent).toContain('Design');
// the remove button is labelled (messages.remove, default "Remove")
host.querySelector<HTMLButtonElement>('button[aria-label="Remove"]')!.click();
fixture.detectChanges();
// (removed) emits after the collapse transition ends
host.dispatchEvent(Object.assign(new Event('transitionend'), { propertyName: 'grid-template-columns' }));
fixture.detectChanges();
// assert your (removed) handler ran, e.g. expect(component.onRemoved).toHaveBeenCalled();
// a <bpdm-notification-badge [count]="5"> renders the count as text ("99+" over max)
// expect(host.textContent).toContain('5');Progress
The Progress bar is a role="progressbar". A determinate bar carries
aria-valuenow (plus aria-valuemin / aria-valuemax) and an aria-valuetext
reflecting the formatted value; an indeterminate one drops aria-valuenow, sets
aria-busy="true", and announces its Loading valuetext (messages.loading). Query
by role and assert those attributes — never the fill colour. No test ids.
import { render, screen } from '@testing-library/react';
import { ProgressBar } from '@bpdm/ui/progress';
// determinate — exposes the exact value
const { unmount } = render(<ProgressBar value={50} />);
const bar = screen.getByRole('progressbar');
expect(bar).toHaveAttribute('aria-valuenow', '50');
expect(bar).toHaveAttribute('aria-valuetext', '50%');
unmount();
// indeterminate — no value, but announces busy + the loading text
render(<ProgressBar indeterminate />);
const busy = screen.getByRole('progressbar');
expect(busy).not.toHaveAttribute('aria-valuenow');
expect(busy).toHaveAttribute('aria-busy', 'true');
expect(busy).toHaveAttribute('aria-valuetext', 'Loading');const el = fixture.nativeElement as HTMLElement; // <bpdm-progress-bar [value]="50" />
// determinate — exposes the exact value
const bar = el.querySelector('[role="progressbar"]') as HTMLElement;
expect(bar.getAttribute('aria-valuenow')).toBe('50');
expect(bar.getAttribute('aria-valuetext')).toBe('50%');
// indeterminate ([indeterminate]="true") drops the value, sets aria-busy,
// and announces the loading text:
// expect(bar.hasAttribute('aria-valuenow')).toBe(false);
// expect(bar.getAttribute('aria-busy')).toBe('true');
// expect(bar.getAttribute('aria-valuetext')).toBe('Loading');Spinner
The Spinner is a role="status" live region whose accessible name is a
visually-hidden label (the per-instance label, else messages.loading, default
"Loading") — so query it by role and assert that text, never the animation. The
LoadingOverlay adds aria-busy="true" and renders its label as a visible
message. No test ids.
import { render, screen } from '@testing-library/react';
import { Spinner, LoadingOverlay } from '@bpdm/ui/spinner';
// the spinner is a status live region named by its hidden label
const { unmount } = render(<Spinner />);
const status = screen.getByRole('status');
expect(status).toHaveTextContent('Loading'); // default label
unmount();
// a per-instance label sets the accessible name
render(<Spinner label="Loading results" />);
expect(screen.getByRole('status')).toHaveTextContent('Loading results');
// the overlay announces busy and shows its visible message
render(<LoadingOverlay show label="Saving…" />);
const overlay = screen.getByRole('status', { name: /Saving/ });
expect(overlay).toHaveAttribute('aria-busy', 'true');
expect(overlay).toHaveTextContent('Saving…');const el = fixture.nativeElement as HTMLElement; // <bpdm-spinner> / <bpdm-loading-overlay>
// the spinner is a status live region named by its hidden label
const status = el.querySelector('[role="status"]') as HTMLElement;
expect(status.textContent).toContain('Loading'); // default label (or your [label])
// the overlay announces busy and shows its visible [label] message
// <bpdm-loading-overlay [show]="true" label="Saving…" />
const overlay = el.querySelector('bpdm-loading-overlay') as HTMLElement;
expect(overlay.getAttribute('aria-busy')).toBe('true');
expect(overlay.textContent).toContain('Saving…');Querying by role and text (Testing Library's recommendation) is the most
robust approach — it works across every component (simple or composed) and
doubles as an accessibility check. The same forwarding shown above also works
for id, aria-*, and any data-* on simple components.