Skip to content

[Feature]: Enable Playwright to Click Accessible, Re-styled checkboxes and radios #35885

Open
@ITenthusiasm

Description

@ITenthusiasm

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


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

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions