Skip to content

Closes #5490 Update text on media video play/pause buttons.#5505

Draft
joshuasosa wants to merge 1 commit intomainfrom
issue/5490
Draft

Closes #5490 Update text on media video play/pause buttons.#5505
joshuasosa wants to merge 1 commit intomainfrom
issue/5490

Conversation

@joshuasosa
Copy link
Copy Markdown
Contributor

@joshuasosa joshuasosa commented Apr 16, 2026

Description

Updates text on media paragraph remote video (YouTube, Vimeo) play/pause button implementation and tab indexing to improve accessibility.

  • Tabbing through elements would previously not reach the play or pause buttons due to them being div elements. This PR changes them to button elements.
  • With the play and pause buttons being button elements, tabbing through elements would encounter both play and pause buttons in the index despite only one shown on screen at a time. This PR replaces the two-button approach with a single button to eliminate potential confusion with tabbing to a hidden button. Note: The current assumption is that videos always autoplay on initial load, leading the PR's initial button state to be set as 'Pause Video'.
  • Tabbing through elements would previously encounter the video iframes in the tab index. This PR sets the YouTube and Vimeo iframes to tabindex -1 to remove them from the tab index since tabbing to them serves no purpose.

Related issues

Closes #5490

How to test

  • Visit the Text on Media demo page and ensure the play/pause button on video elements still works.
  • Visit the Text on Media demo page and use keyboard tabbing to tab to a text on media paragraph with video. See if the pause/play button can be tabbed to. Use the keyboard 'enter' key to click the button to pause and resume the video.
  • Create a new page and add a text on media paragraph with a Vimeo video. Check that the above two points work for the Vimeo video.

Types of changes

Arizona Quickstart (install profile, custom modules, custom theme)

  • Patch release changes
    • Bug fix
    • Accessibility, performance, or security improvement
    • Critical institutional link or brand change
    • Adding experimental module
    • Update experimental module
  • Minor release changes
    • New feature
    • Breaking or visual change to existing behavior
    • Upgrade experimental module to stable
    • Enable existing module by default or database update
    • Non-critical brand change
    • New internal API or API improvement with backwards compatibility
    • Risky or disruptive cleanup to comply with coding standards
    • High-risk or disruptive change (requires upgrade path, risks regression, etc.)
  • Other or unknown
    • Other or unknown

Drupal core

  • Patch release changes
    • Security update
    • Patch level release (non-security bug-fix release)
    • Patch removal that's no longer necessary
  • Minor release changes
    • Major or minor level update
  • Other or unknown
    • Other or unknown

Drupal contrib projects

  • Patch release changes
    • Security update
    • Patch or minor level update
    • Add new module
    • Patch removal that's no longer necessary
  • Minor release changes
    • Major level update
  • Other or unknown
    • Other or unknown

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • My change requires release notes.

@joshuasosa joshuasosa self-assigned this Apr 16, 2026
@joshuasosa joshuasosa requested review from a team as code owners April 16, 2026 22:51
@joshuasosa joshuasosa linked an issue Apr 16, 2026 that may be closed by this pull request
@az-digital-bot
Copy link
Copy Markdown
Contributor

Tugboat has finished building the preview for this pull request!

Link:

Dashboard:

@joeparsons joeparsons added accessibility bug Something isn't working backport-2.x Changes to be back-ported to the 2.x development branch patch release Issues to be included in the next patch release labels Apr 17, 2026
@joeparsons joeparsons moved this from Todo to Needs review in 3.3.5 bug-fix patch release Apr 17, 2026
@kevdevlu kevdevlu self-requested a review April 17, 2026 17:49
@github-project-automation github-project-automation Bot moved this from Needs review to Ready to merge in 3.3.5 bug-fix patch release Apr 17, 2026
@kevdevlu
Copy link
Copy Markdown
Member

If it's okay, i would like to test this against the az_gdpr_consent module which prevents embedded videos from auto-playing for GDPR countries

Copy link
Copy Markdown
Contributor

@accesswatch accesswatch left a comment

Choose a reason for hiding this comment

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

Thank you for this PR — the divbutton conversion and the iframe tabindex="-1" change are exactly the right direction, and consolidating to a single toggle button is much cleaner than the two-button z-index approach.

I do want to flag a few accessibility issues before this merges:

1. aria-hidden="" on the button — blocking

The new template renders:

<button ... aria-hidden="">Pause Video</button>

An empty aria-hidden attribute is parsed as aria-hidden="true" by browsers and screen readers. This hides the button from the accessibility tree entirely, which means keyboard and AT users cannot discover or activate it — the opposite of what this PR is trying to achieve. The aria-hidden attribute should be removed completely from the button.

2. State detection via textContent comparison — fragile

Both JS files determine play/pause state with:

if (event.currentTarget.textContent === 'Play Video')

This will break if the string is ever translated, whitespace changes, or the DOM is modified. A data-state="playing|paused" attribute (or aria-pressed) on the button would be more robust and easier to test.

3. aria-pressed missing on the toggle button

A toggle button should convey its state to assistive technology via aria-pressed="true" (paused) / aria-pressed="false" (playing), updated in JS alongside the label change. The current approach changes visible text and title, which works for sighted users but AT users who don't re-read the button after activation won't get state feedback.

4. Minor: title and textContent mismatch on initial render

The initial button has title="Pause the Video" but textContent="Pause Video" (missing "the"). Not a functional issue but worth tidying for consistency.


These are all fixable — happy to suggest specific code if helpful. The core approach here is solid and this is very close to being a clean accessibility improvement.

@accesswatch
Copy link
Copy Markdown
Contributor

Here are concrete code suggestions for each issue:


Fix 1 + 3 + 4: Template — remove aria-hidden, add aria-pressed, fix title casing

media--az-remote-video--az-background.html.twig

-<button type="button" class="bg-video-player-control btn btn-light az-video-playpause video-playpause" title="Pause the Video" aria-hidden="">Pause Video</button>
+<button type="button" class="bg-video-player-control btn btn-light az-video-playpause video-playpause" title="Pause the video" aria-pressed="false">Pause Video</button>
  • aria-hidden="" removed — the button is now visible to assistive technology.
  • aria-pressed="false" added — on initial load the video is playing and pause has not been activated. When the user pauses, JS sets this to "true". This is the standard toggle button pattern (see APG Toggle Button).
  • title lowercased to match the JS-set value and the textContent ("Pause the video").

Fix 2 + 3: Vimeo JS — use aria-pressed for state, not textContent

az_paragraphs_az_text_media_vimeo.js

-          playPauseButton.addEventListener('click', (event) => {
-            event.preventDefault();
-            if (event.currentTarget.textContent === 'Play Video') {
-              element.player.play().catch((error) => vimeoError(error));
-              parentParagraph.classList.remove('az-video-paused');
-              parentParagraph.classList.add('az-video-playing');
-              event.currentTarget.textContent = 'Pause Video';
-              event.currentTarget.setAttribute('title', 'Pause the video');
-            } else {
-              element.player.pause().catch((error) => vimeoError(error));
-              parentParagraph.classList.remove('az-video-playing');
-              parentParagraph.classList.add('az-video-paused');
-              event.currentTarget.textContent = 'Play Video';
-              event.currentTarget.setAttribute('title', 'Play the video');
-            }
-          });
+          playPauseButton.addEventListener('click', (event) => {
+            event.preventDefault();
+            const btn = event.currentTarget;
+            const isPaused = btn.getAttribute('aria-pressed') === 'true';
+            if (isPaused) {
+              element.player.play().catch((error) => vimeoError(error));
+              parentParagraph.classList.remove('az-video-paused');
+              parentParagraph.classList.add('az-video-playing');
+              btn.textContent = 'Pause Video';
+              btn.setAttribute('title', 'Pause the video');
+              btn.setAttribute('aria-pressed', 'false');
+            } else {
+              element.player.pause().catch((error) => vimeoError(error));
+              parentParagraph.classList.remove('az-video-playing');
+              parentParagraph.classList.add('az-video-paused');
+              btn.textContent = 'Play Video';
+              btn.setAttribute('title', 'Play the video');
+              btn.setAttribute('aria-pressed', 'true');
+            }
+          });

Fix 2 + 3: YouTube JS — same pattern

az_paragraphs_az_text_media_youtube.js

-              playPauseButton.addEventListener('click', (event) => {
-                event.preventDefault();
-                if (event.currentTarget.textContent === 'Play Video') {
-                  element.player.playVideo();
-                  parentParagraph.classList.remove('az-video-paused');
-                  parentParagraph.classList.add('az-video-playing');
-                  event.currentTarget.textContent = 'Pause Video';
-                  event.currentTarget.setAttribute('title', 'Pause the video');
-                } else {
-                  element.player.pauseVideo();
-                  parentParagraph.classList.remove('az-video-playing');
-                  parentParagraph.classList.add('az-video-paused');
-                  event.currentTarget.textContent = 'Play Video';
-                  event.currentTarget.setAttribute('title', 'Play the video');
-                }
-              });
+              playPauseButton.addEventListener('click', (event) => {
+                event.preventDefault();
+                const btn = event.currentTarget;
+                const isPaused = btn.getAttribute('aria-pressed') === 'true';
+                if (isPaused) {
+                  element.player.playVideo();
+                  parentParagraph.classList.remove('az-video-paused');
+                  parentParagraph.classList.add('az-video-playing');
+                  btn.textContent = 'Pause Video';
+                  btn.setAttribute('title', 'Pause the video');
+                  btn.setAttribute('aria-pressed', 'false');
+                } else {
+                  element.player.pauseVideo();
+                  parentParagraph.classList.remove('az-video-playing');
+                  parentParagraph.classList.add('az-video-paused');
+                  btn.textContent = 'Play Video';
+                  btn.setAttribute('title', 'Play the video');
+                  btn.setAttribute('aria-pressed', 'true');
+                }
+              });

Why aria-pressed works here: A screen reader user hears "Pause Video, toggle button, not pressed" when the video is playing, and "Play Video, toggle button, pressed" when it is paused. The state is communicated through both the label change and the pressed state — AT users who navigate away and return get the full picture without having to activate the button.

Happy to answer any questions about these changes.

@accesswatch
Copy link
Copy Markdown
Contributor

accesswatch commented Apr 20, 2026

Accessibility review — this PR is the right direction, one blocking one-liner before merge

Converting <div> controls to a single <button>, eliminating the hidden-element tab confusion, and setting tabindex="-1" on the iframes are all the right moves.


1. aria-hidden="" on the button — remove it (blocking, one-line fix)

The template currently has:

<button ... aria-hidden="">Pause Video</button>

An empty-string value for aria-hidden is ambiguous — some assistive technologies may interpret it as aria-hidden="true" and hide the button from the accessibility tree entirely. Since this button is the primary interactive control, the attribute should be removed:

-<button type="button" class="bg-video-player-control btn btn-light az-video-playpause video-playpause" title="Pause the Video" aria-hidden="">Pause Video</button>
+<button type="button" class="bg-video-player-control btn btn-light az-video-playpause video-playpause">Pause Video</button>

The title attribute is also redundant once the button has descriptive visible text — screen readers read the text content directly, so the title adds nothing and can be removed alongside aria-hidden.


2 & 3. Autoplay state mismatch + aria-pressed toggle semantics

Filed as a follow-up in #5515 — these don’t need to block this PR.

@accesswatch
Copy link
Copy Markdown
Contributor

I took a close look at this PR. The overall direction is good: changing the controls from div elements to a real button, collapsing the two-button setup into one control, and taking the iframe out of the tab order are all the right moves.

I do see two issues I’d recommend fixing before merge:

  1. The new button should not have aria-hidden on it.

Right now the template renders the only play/pause control with aria-hidden="". Since this is now the real interactive control, it needs to stay in the accessibility tree. Depending on browser and assistive tech behavior, that attribute can cause the button to be ignored or announced inconsistently.

Technical recommendation:

  • Remove aria-hidden from the button entirely.
  • I’d also drop the title unless it is adding something the visible text does not already provide.
  1. The initial button state is not always accurate.

This PR initializes the control as Pause Video, which assumes the video is already playing. That is not always true in this codebase.

There are at least two cases where that assumption breaks:

  • autoplay can be turned off via the formatter settings
  • the GDPR consent module can block the YouTube/Vimeo API until consent is granted

In those cases, the page can show a focusable Pause Video button even though there is no playing video yet. That is confusing for keyboard users and screen reader users, because the control label does not match the actual state.

Technical recommendation:

  • Set the button label from the real player state after initialization instead of assuming autoplay succeeded.
  • Use one small state-sync function for both players, something like syncPlayPauseButton(isPlaying), and have it update the label, optional title, and ideally aria-pressed or a data-state attribute.
  • Call that sync function on ready/init, on play, and on pause instead of using textContent as the source of truth.

If you want the most robust version, I’d suggest:

  • remove aria-hidden
  • detect actual initial state after player ready / consent flow
  • track state with aria-pressed or data-state instead of comparing button text

With those changes, this becomes a solid accessibility improvement.

@accesswatch
Copy link
Copy Markdown
Contributor

Following up with the specific line-level note here since I couldn’t attach it without a pending review:

On the new button in media--az-remote-video--az-background.html.twig:

This new button should not have aria-hidden on it. It is now the only interactive play/pause control, so it needs to stay exposed to the accessibility tree for screen reader users. Please remove the attribute entirely rather than leaving it empty. I’d also consider dropping the title here unless it adds something the visible button text does not already communicate.

@accesswatch
Copy link
Copy Markdown
Contributor

Separate note on the play/pause state logic:

I think the initial button state needs to come from the actual player state, not from the assumption that autoplay already happened.

Right now the control is rendered as Pause Video, and the JS toggle logic is based on the current button text. That only works if the video is already playing on first render. In this repo that is not always true:

  • autoplay can be disabled in the formatter settings
  • the GDPR consent module can block the YouTube/Vimeo API until consent is granted

In those cases, a keyboard or screen reader user can land on a focusable Pause Video button even though nothing is currently playing yet.

Technical recommendation:

  • after player ready and after any consent handoff, read the real player state and set the button label from that state
  • use a small shared state-sync function so both YouTube and Vimeo update the button the same way
  • avoid using textContent as the source of truth for behavior; use aria-pressed or a data-state attribute instead, then update the visible label from that state

That would make the control more reliable and keep the accessible name aligned with what the player is actually doing.

@joshuasosa
Copy link
Copy Markdown
Contributor Author

@accesswatch are your recommendations/suggestions mostly captured already in #5516 ? I reviewed these recommendations with that PR and think they sound fine to implement. I'd suggest completing that PR that's targeted to merge into this one and then we can proceed with review of this one.

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

Labels

accessibility backport-2.x Changes to be back-ported to the 2.x development branch bug Something isn't working patch release Issues to be included in the next patch release

Projects

Status: Ready to merge

Development

Successfully merging this pull request may close these issues.

Text on media video pause button not keyboard accessible

7 participants