Description
🚀 Feature Request
Enable Playwright to interact with <input type="checkbox">
s and <input type="radio">
s that are hidden for re-styling purposes but still fully-accessible to all users (including those without JavaScript).
(Relates to #12267 (comment).)
Example
If this feature was supported, commands like the following would become possible in Playwright for re-styled checkbox
es and radio
s:
await page.getByRole("checkbox", { name: "Check Me" }).click();
await page.getByRole("checkbox", { name: "Check Me" }).setChecked(false);
await page.getByRole("radio", { name: "Option 1" }).click();
await page.getByRole("radio", { name: "Option 1" }).setChecked(true);
There might be other commands that I'm missing, but you get the idea. (To define "re-styled", see the Motivation
section of this OP.)
Motivation
Oftentimes, developers/companies will want to apply custom styles to an <input type="checkbox">
or an <input type="radio">
. Unfortunately, modern browsers do not provide an easy way for developers to accomplish this. Consequently, the standard has been to do the following:
- Hide these inputs (without taking them out of the Tab Order or making them inaccessible to Screen Readers)
- Use the associated
<label>
element to receive clicks, toggle thecheckbox
/radio
, and display various states like:checked
or:focus
If you're unfamiliar with this pattern, here's a very rough idea of how this might be implemented:
Show Code Sample
<html>
<head>
<style>
.visually-hidden {
/* Do not obscure the DOM layout. */
position: absolute;
padding: 0;
border: 0;
margin: 0;
/* Place text in a screen-readable (non-zero-sized) block. Then clip the block so that it isn't visible (to the eye). */
width: 1px;
height: 1px;
clip-path: inset(50%);
/* Help screen readers see full sentences (instead of multi-lined, separated letters). */
white-space: nowrap;
/* Disable scrolling */
overflow: hidden;
}
input[type="checkbox"].visually-hidden + label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
&::before {
content: "";
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
width: var(--checkbox-size);
height: var(--checkbox-size);
padding: 2px;
border: 1px solid black;
border-radius: 4px;
background-color: white;
}
&:is(input:checked + label)::before {
content: "\00D7" / "";
}
&:is(input:focus-visible + label)::before {
border-color: dodgerblue;
outline: 1px solid dodgerblue;
}
}
</style>
</head>
<body>
<div>Some Text</div>
<input id="please-find-me" class="visually-hidden" type="checkbox" />
<label for="please-find-me" style="--checkbox-size: 16px">Check Me</label>
<div>Some More Text</div>
</body>
</html>
If you play with this (e.g., on MDN Playground), you'll find that this checkbox
solution:
- Is accessible to keyboard users
- Is accessible to mouse users
- Is accessible to screen reader users
- Works without JavaScript (very important)
However, Playwright does not recognize this, and it will fail if we attempt to toggle this checkbox with something like
await page.getByRole("checkbox", { name: "Check Me" }).click();
According to Playwright, this test fails for the following reason:
<label for="please-find-me">Check Me</label>
intercepts pointer events
But this behavior is exactly what developers want! And it creates a perfectly-accessible user experience as well. You can see an example of this problem at: https://github.com/ITenthusiasm/playwright-issue-checkboxes. Again, the same problem will occur for radio
s.
Possible Implementations
Playwright's logic wouldn't need to change too much to support this use case. If Playwright can't click
a re-styled checkbox
or radio
, it can simply try clicking one of its associated labels instead. Whatever actionability Playwright tests for input:is([type="checkbox"], [type="radio"])
, it can simply re-run that logic on the available input.labels
and consider everything good to go if actionability is possible on a (valid) owning label.
For bonus points, Playwright can ignore all <label>
elements in input.labels
whose text content doesn't match the name
passed to the getByRole()
call -- to be on the safe side.
Alternatively, if the team feels iffy about applying this logic exclusively to checkbox
es and radio
s, then perhaps Playwright could support an option like includeLabels
to indicate that Playwright should feel free to check for a semantic, accessible, associated <label>
with text content that matches the <input>
of interest:
await page.getByRole("checkbox", { name: "Check Me" }).click({ includeLabels: true });
However, Playwright would need to support this option for all relevant commands (like setChecked()
). And at the end of the day, I'm not sure if this would really be relevant (or should even be allowed) for anything that isn't a checkbox
or radio
.
(Invalid) Workarounds
There are workarounds to this problem, but they are either unorthodox or a poor DX. This section is optional. Feel free to expand it if desired, though.
Dissatisfactory Workarounds (3)
1) Directly Clicking Elements with JavaScript
Developers can technically do either of the following themselves
const checkbox = page.getByRole("checkbox", { name: "Check Me" });
await checkbox.evaluate((c) => c.click());
await checkbox.evaluate((c) => c.labels?.[0].click());
But that defeats the whole point of running Playwright. We want Playwright to verify that the owning <label>
(which we've re-purposed to display the checkbox
state to visual users) is in fact clickable, and that clicking it produces the desired result (toggling the checkbox
, etc.).
2) Getting the <label>
by Text
Developers can also do
const checkboxLabelText = "Check Me";
const checkboxLabel = page.getByText(checkboxLabelText);
await checkboxLabel.click();
But there are two problems with this approach:
- It assumes that no other elements on the page have similar text
- It is less convenient than simply calling
click()
on thecheckbox
(which is effectively what the user will be doing)
The 1st issue in this list can be resolved by using getByTestId()
. But like the previous workaround, this is an anti-pattern.
3) Focusing the checkbox
and Pressing SpaceBar
const checkbox = page.getByRole("checkbox", { name: "Check Me" });
await checkbox.focus();
await page.keyboard.press(" ");
For developers simply desiring to toggle the checkbox in a valid, accessible manner, this technically gets the job done. But for developers desiring to prove that the checkbox is accessible to Mouse Users, this test does nothing at all.
Additionally, this solution may not be 100% reliable. (Do we know for certain that the checkbox doesn't match [tabindex="-1"]
? A better test for accessibility here would be to use page.keyboard.press("Tab")
. But now you have to search for the place in the Document where pressing Tab
would lead you to the checkbox
. This is again inconvenient.)