Skip to content

Emit valid values for aria-pressed on the Customizer device buttons - #13046

Open
westonruter wants to merge 1 commit into
WordPress:trunkfrom
westonruter:fix/aria-attribute-boolean-values
Open

Emit valid values for aria-pressed on the Customizer device buttons#13046
westonruter wants to merge 1 commit into
WordPress:trunkfrom
westonruter:fix/aria-attribute-boolean-values

Conversation

@westonruter

Copy link
Copy Markdown
Member

The responsive-preview buttons in the Customizer emit invalid values for aria-pressed, so none of the three is exposed to assistive technology as a toggle button on initial render.

The defect

wp-admin/customize.php rendered the attribute through esc_attr() with a bool:

$active = ! empty( $settings['default'] );
...
<button type="button" ... aria-pressed="<?phpechoesc_attr( $active ); ?>" ...>

PHP casts true to '1' and false to the empty string. Since only the Desktop device carries 'default' => true, the markup came out as:

<button...aria-pressed="1" data-device="desktop"><button...aria-pressed="" data-device="tablet"><button...aria-pressed="" data-device="mobile">

aria-pressed is a tristate attribute whose only valid values are true, false, mixed, and undefined. Both 1 and the empty string are invalid, and an invalid or empty value is treated as undefined, which means the element is not exposed as a toggle button at all. So on page load the pressed state of all three buttons was unavailable to screen reader users, and the currently selected preview size was not announced.

The state does become correct after the first interaction, because customize-controls.js sets it via jQuery:

.attr('aria-pressed',false);
...
.attr('aria-pressed',true);

jQuery stringifies those to "false" and "true", which are valid. So the JavaScript was always right and the server render disagreed with it — the markup only repaired itself once the user clicked something.

The fix

Emit the literals directly, so the initial render agrees with what the JavaScript later writes:

aria-pressed="<?phpecho$active ? 'true' : 'false'; ?>"

No escaping is needed since both values are literals. This matches the existing pattern for aria-expanded in wp-admin/includes/template.php, which assigns 'true'/'false' as strings.

Every aria-pressed, aria-expanded, aria-selected, aria-checked, aria-disabled, aria-current, and aria-invalid value rendered from PHP across src/wp-admin, src/wp-includes, and src/wp-content/themes was checked. This was the only one not already a valid literal.

Also included

wp-admin/options-general.php used the same bool-to-string cast for the Site Icon button's data-state:

data-state="<?phpechoesc_attr( has_site_icon() ); ?>"

That one is not a defect. site-icon.js compares against the literal '1' and writes back '1'/'', so the cast happened to produce exactly the sentinels the script expects. It is changed to has_site_icon() ? '1' : '' purely to state that contract rather than leave it resting on PHP's cast rules. Behaviour is identical.

It is included here because these were the only two places in core passing a bool to an escaping function. Commit r63296 widened the esc_*() annotations to string|int|float and deliberately excluded bool so that both call sites stayed visible rather than being silently permitted by the signature; resolving them empties the last of the esc_*() entries from tests/phpstan/baselines/argument.type.neon.

Trac ticket: Core-65817

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Identifying the invalid attribute values, sweeping core for other occurrences, the fix itself, and drafting this description. Reviewed by me.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

The responsive-preview buttons in the Customizer rendered aria-pressed via
esc_attr( $active ), where $active is a bool. PHP casts true to '1' and
false to '', so the desktop button emitted aria-pressed="1" and the tablet
and mobile buttons emitted aria-pressed="".
Neither is a valid value. aria-pressed is a tristate attribute accepting
only "true", "false", "mixed", or "undefined", and an invalid or empty
value is treated as "undefined" — meaning the button is not exposed as a
toggle at all. All three buttons were therefore announced without their
pressed state on initial render, until the first interaction caused
controls.js to rewrite the attribute via jQuery, which stringifies the
bool correctly to "true"/"false".
Emit the literals directly so the server render agrees with what the
JavaScript later writes. This matches the existing pattern used for
aria-expanded in wp-admin/includes/template.php.
Also makes the sentinel explicit for the Site Icon button's data-state
attribute, which relied on the same bool-to-string cast to produce the
'1' and '' values that site-icon.js compares against and writes back.
Behaviour is unchanged there; the contract is now stated rather than
implied.
Regenerating the baselines drops the last two esc_*() entries from
tests/phpstan/baselines/argument.type.neon, which no longer has any.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actionsBot commented Aug 14, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props westonruter, afercia.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@afercia

Copy link
Copy Markdown
Member

So on page load the pressed state of all three buttons was unavailable t
The state does become correct after the first interaction, because customize-controls.js sets it via jQuery:

While I do see aria-pressed="" / aria-pressed="1" in the markup, on page load I see in the DOM they have already been changed to true / false before any interaction. Am I missing something?

Anyways, the fix makes totally sense. Good catch.

@aferciaafercia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@westonruter

Copy link
Copy Markdown
MemberAuthor

Am I missing something?

@afercia no, I don't think you're missing anything. This is a minor correctness fix which probably won't have any a11y impact.

Thank you for reviewing.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@westonruter@afercia