playwright icon indicating copy to clipboard operation
playwright copied to clipboard

[Feature]: Enable Playwright to Click Accessible, Re-styled `checkbox`es and `radio`s

Open ITenthusiasm opened this issue 8 months ago • 7 comments

🚀 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 https://github.com/microsoft/playwright/issues/12267#issuecomment-1499146439.)


Example

If this feature was supported, commands like the following would become possible in Playwright for re-styled checkboxes and radios:

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:

  1. Hide these inputs (without taking them out of the Tab Order or making them inaccessible to Screen Readers)
  2. Use the associated <label> element to receive clicks, toggle the checkbox/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:

  1. Is accessible to keyboard users
  2. Is accessible to mouse users
  3. Is accessible to screen reader users
  4. 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 radios.


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 checkboxes and radios, 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.

Perhaps a better approach would be to apply includeLabels to the getByRole method itself. This would be similar to the includeHidden option:

await page.getByRole("checkbox", { name: "Check Me", includeLabels: true }).click();

(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:

  1. It assumes that no other elements on the page have similar text
  2. It is less convenient than simply calling click() on the checkbox (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, and it unnecessarily pollutes the DOM.

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.)

ITenthusiasm avatar May 07 '25 22:05 ITenthusiasm

While this issue is collecting feedback/votes, I do just want to point out that @leo-petrucci's comment on this matter got 12 separate upvotes -- just to put things in perspective so that it doesn't look like this issue is only of interest to 2 or 3 people.

Just wanted the team to keep that in perspective. Thanks!

ITenthusiasm avatar May 12 '25 13:05 ITenthusiasm

I have had this issue. Someone let me know that there was this new feature request and asked me to upvote it. How do I upvote it?

mdnahas avatar May 12 '25 13:05 mdnahas

@mdnahas You just have to click the 👍🏾 icon that appears at the bottom of this Feature Request, and the counter should go up. Do you see the icon?

(Of course, this is only if you're interested in seeing this feature in Playwright.)

ITenthusiasm avatar May 12 '25 13:05 ITenthusiasm

@ITenthusiasm It isn't very prominent! Found and pressed.

mdnahas avatar May 12 '25 20:05 mdnahas

THIS !

dacloutier-logmein avatar Jul 01 '25 11:07 dacloutier-logmein

Updated the OP to include an alternative to the includeLabels idea. If the team considered such an option more preferable than implicitly searching for radio/checkbox labels, it might be simpler to expose that option on the Locator.getByRole method instead of exposing it on the various commands. (This would probably result in a simpler implementation too, I imagine.)

So instead of this:

const checkbox = page.getByRole("checkbox", { name: "Check Me" });
await checkbox.click({ includeLabels: true });
await checkbox.setChecked(false, { includeLabels: true });

We'd have this:

const checkbox = page.getByRole("checkbox", { name: "Check Me", includeLabels: true });
await checkbox.click();
await checkbox.setChecked(false);

This is somewhat similar to the includeHidden option that Locator.getByRole already supports.

One benefit of this approach over implicitly searching for radio/checkbox label elements whenever getByRole is called is that this would be a non-breaking change. I cannot possibly imagine why someone would want a checkbox/radio to be accessible but not any corresponding <label>s, but having a new feature not impact previous behavior might be valuable. Additionally, explicit code is probably more readable than implicit code.


Another idea for includeLabels (probably not that great)

While I'm on the matter, yet another approach could be to make includeLabels more explicit. For example:

const checkbox = page.getByRole("checkbox", { name: "Check Me", includeLabel: "My Label" });

or

const checkbox = page.getByRole("checkbox", { name: "Check Me", includeLabels: ["My Label", "2nd Label"] });

I don't know the value in this approach though. A radio/checkbox really only needs one associated label. And the <label>'s text content should already be implied by the supplied name (if the markup is written well). Nonetheless, I figured I'd express the idea.

ITenthusiasm avatar Jul 31 '25 16:07 ITenthusiasm

Our team is running into this exact limitation, even with a completely vanilla export of react-aria-components. For example:

<>
  {label ? <Label htmlFor={name}>{label}</Label> : null}
  <RadioGroup
    onChange={handleChange}
    defaultValue={defaultValueString}
    value={valueString}
    type={type}
    name={name}
    orientation="horizontal"
  >
    <Radio value="yes">Yes</Radio>
    <Radio value="no">No</Radio>
  </RadioGroup>
  {error ? <FieldError error={error} name={name} /> : null}
</>

Even though this pattern is fully accessible and works perfectly with keyboard + screen readers, Playwright still can’t interact with it. Both:

await page.getByLabel("Yes").check();

and

await page.getByLabel("Yes").click();

fail for the same “intercepts pointer events” reason mentioned in the OP.

So even with a minimal, standards-compliant setup, Playwright can’t toggle these radios without resorting to workarounds. Just adding another data point showing how common this pattern is in modern accessible component libraries, and how valuable native support here would be.

darlingdavanzo avatar Nov 14 '25 08:11 darlingdavanzo