Part 09 · Interaction Toolkit Card 45
Native Form Controls
Card 45: Native Form Controls
The portable agent skills for this pattern are playwright-interactions, playwright-locators and playwright-assertions.
What This Pattern Solves
Text inputs, checkboxes, radios, selects and buttons each carry state the browser already tracks: value, checked, selected, disabled, accessible description. You get flaky or wrong tests when you drive these controls with generic click() calls and read state back with getAttribute or innerText. Playwright has one action and one matcher for each control. Use them and the test reads like the form.
How It Works
- Text:
fill()replaces the value in one input event.clear()empties it.pressSequentially()types key by key for widgets that listen to keys. - Checkboxes and radios:
check(),uncheck()andsetChecked(bool)are idempotent and verify the result. Assert withtoBeChecked(). - Selects:
selectOption()takes a value, a{ label }, or an array formultiple. Assert withtoHaveValue()ortoHaveValues(). - Pointer:
hover()for CSS:hovercontent,dblclick(), andclick({ button: 'right' })for context menus. - State:
toBeDisabled()/toBeEnabled()instead of trying to click. - Validation:
toHaveAccessibleDescription()proves the error belongs to the field, andgetByRole('alert')covers the form-level summary.
Code Example
await page.getByLabel('Accept terms').check();
await expect(page.getByLabel('Accept terms')).toBeChecked();
await page.getByLabel('Country').selectOption({ label: 'Canada' });
await expect(page.getByLabel('Country')).toHaveValue('ca');
await page.getByLabel('Toppings').selectOption(['cheese', 'olives']);
await expect(page.getByLabel('Toppings')).toHaveValues(['cheese', 'olives']);
// Bad: the text exists somewhere on the page, maybe next to the wrong field.
await expect(page.getByText('Enter a valid email')).toBeVisible();
// Good: a screen reader hears this error on this field.
await expect(page.getByLabel('Email')).toHaveAccessibleDescription('Enter a valid email');
Key Concepts
fill()vspressSequentially():fillsets the value and fires oneinputevent. Key handlers see nothing. Reach forpressSequentiallyonly when the widget reacts to individual keys.clear(): Sends a Delete key after selecting the text, so key counters see one extra keydown.check()vsclick():clicktoggles.checkleaves a checked box checked and throws if the box ends up unchecked.setChecked(value): Drive the target state from test data.selectOption(string): Matches by value or label. Pass{ value }or{ label }to be explicit.toHaveValues([...]): The matcher for<select multiple>.toHaveAccessibleDescription(): Readsaria-describedby, the link between a field and its error text.- Radio scoping: Wrap radios in a
<fieldset>with a<legend>and scope withgetByRole('group', { name }).
When to Use This Pattern
- ✓ Any form built on native
<input>,<select>and<button>. - ✓ Asserting validation messages land on the right field.
- ✓ Hover menus and tooltips driven by CSS
:hover. - ✗ Custom comboboxes and listboxes (Card 40, shadcn components).
- ✗ Keyboard shortcuts and drag and drop (Card 48).
Related Patterns
- Previous: Card 44 (Agentic MCP → CI).
- Next: Card 46 (Browser Dialogs).
- Complementary: Card 17 (Accessibility with axe), Card 39 (Accessible Components), Card 40 (shadcn Components).
Isolated examples
Each control is built with page.setContent inside the spec. Run the tests to see each action paired with its matcher.
Run This Example
pnpm test src/45-native-form-controls