Skip to content

a11y: video play/pause button — aria-pressed, event-driven state, fix aria-hidden (closes #5515)#5516

Merged
joshuasosa merged 4 commits intoissue/5490from
a11y/5515-video-button-aria-pressed
May 5, 2026
Merged

a11y: video play/pause button — aria-pressed, event-driven state, fix aria-hidden (closes #5515)#5516
joshuasosa merged 4 commits intoissue/5490from
a11y/5515-video-button-aria-pressed

Conversation

@accesswatch
Copy link
Copy Markdown
Contributor

Description

Follow-up to #5505 (closes #5515).

#5505 replaced the two-button pattern with a single <button> and fixed tabindex on iframes — all the right moves. This PR addresses two issues left open as follow-up:

1. Autoplay state mismatch (bug)

The original button started as "Pause Video" under the assumption that autoplay always succeeds. When autoplay is blocked — by browser policy, az_gdpr_consent, or user preference (as noted by @kevdevlu) — the video is not playing but the button says "Pause". A screen reader user hears "Pause Video, button" and presses it expecting to pause, but it starts playback instead.

Fix: The button now defaults to "Play Video" / aria-pressed="true" (conservative: not confirmed playing). The actual player events (play/pause for Vimeo; onPlayerStateChange states 1 and 2 for YouTube) drive all state updates. The button is always accurate regardless of autoplay outcome.

2. aria-hidden="" removed and aria-pressed added (accessibility)

  • aria-hidden="" on the button had an ambiguous empty-string value — some AT treat it as aria-hidden="true", hiding the only interactive control from the accessibility tree.
  • aria-pressed is now used for toggle semantics. Screen readers announce: "Play Video, toggle button, pressed" (paused state) and "Pause Video, toggle button, not pressed" (playing state). State is programmatically determinable independent of the label.
  • The redundant title attribute is removed — visible text content is sufficient for the accessible name.

Changes

File Change
media--az-remote-video--az-background.html.twig Remove aria-hidden="", remove title, add aria-pressed="true", set initial label to "Play Video"
az_paragraphs_az_text_media_vimeo.js Event-driven state: play/pause events sync button; click handler only calls play/pause
az_paragraphs_az_text_media_youtube.js onPlayerStateChange states 1 and 2 sync button; click handler uses aria-pressed

How to test

  • Visit a Text on Media paragraph with a YouTube or Vimeo background video
  • With autoplay enabled: button should show "Play Video" briefly, then switch to "Pause Video" once the player fires its play event
  • With autoplay blocked (e.g., via az_gdpr_consent or browser devtools throttling): button should remain "Play Video" — pressing it starts playback; button then switches to "Pause Video"
  • Press the button to pause: it should show "Play Video" and aria-pressed="true"
  • Press again to resume: it should show "Pause Video" and aria-pressed="false"
  • Verify with a screen reader (NVDA/JAWS/VoiceOver) that the button is announced and state changes are heard

Related

Types of changes

Arizona Quickstart

  • Patch release changes
    • Bug fix
    • Accessibility, performance, or security improvement

…dden

Closes #5515

- Remove aria-hidden="" from button (was hiding it from AT)
- Remove redundant title attribute (visible text content is sufficient)
- Set aria-pressed="true" as initial default (conservative: video not
  confirmed playing until player fires its play event)
- Change button initial label to "Play Video" (matches aria-pressed state)

Vimeo:
- Replace textContent comparison in click handler with aria-pressed check
- Replace bufferend class management with dedicated play/pause events
- play event: sets "Pause Video" + aria-pressed="false" + classes
- pause event: sets "Play Video" + aria-pressed="true" + classes
- Click handler: only calls play()/pause(); events drive all state

YouTube:
- Replace textContent comparison in click handler with aria-pressed check
- onPlayerStateChange state 1: syncs button to "Pause Video" + aria-pressed="false"
- onPlayerStateChange state 2: syncs button to "Play Video" + aria-pressed="true"
- Both cases also manage az-video-playing/az-video-paused classes
…ure)

Revert the split template literal at L48 back to a single line as
required by prettier. The line is within the 80-char print-width limit
so no wrap is needed.
- Revert split `if (window.screen && ...)` back to one line (L5)
- Revert split `const firstScriptTag` back to one line (L20)
- Remove unused `parentParagraph` variable (L31): class state updates
  were moved to onPlayerStateChange; the click handler no longer needs
  to reference the parent container directly
- Wrap long `getAttribute('aria-pressed')` condition per prettier
  print-width rules (L65)
@accesswatch accesswatch marked this pull request as ready for review April 23, 2026 19:02
@accesswatch accesswatch requested review from a team as code owners April 23, 2026 19:02
Copilot AI review requested due to automatic review settings April 23, 2026 19:02
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Improves accessibility and correctness of the background video play/pause control for YouTube/Vimeo by moving to event-driven UI state updates and removing problematic ARIA attributes.

Changes:

  • Updates the Twig template to remove aria-hidden=""/title, add aria-pressed, and set an initial “Play Video” label.
  • Refactors YouTube logic so onPlayerStateChange drives the button state, and click only triggers play/pause.
  • Refactors Vimeo logic so play/pause events drive the button state, and click only triggers play/pause.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.

File Description
modules/custom/az_paragraphs/az_paragraphs_text_media/templates/media--az-remote-video--az-background.html.twig Updates initial button markup (label + ARIA attributes).
modules/custom/az_paragraphs/az_paragraphs_text_media/js/az_paragraphs_az_text_media_youtube.js Makes button state event-driven via YouTube player state changes.
modules/custom/az_paragraphs/az_paragraphs_text_media/js/az_paragraphs_az_text_media_vimeo.js Makes button state event-driven via Vimeo play/pause events.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -156,6 +148,22 @@
resize();
parentContainer.classList.add('az-video-playing');
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

When the player enters the playing state (event.data === 1), the code adds az-video-playing but never removes az-video-paused. After a pause/resume cycle the container can end up with both classes, which can cause conflicting styling/behavior. Remove az-video-paused when transitioning to playing.

Suggested change
parentContainer.classList.add('az-video-playing');
parentContainer.classList.add('az-video-playing');
parentContainer.classList.remove('az-video-paused');

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +156
// Sync button state: video is confirmed playing.
const btn = parentContainer.querySelector('.az-video-playpause');
if (btn) {
btn.textContent = 'Pause Video';
btn.setAttribute('aria-pressed', 'false');
}
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

In the confirmed playing state (event.data === 1), the button is set to "Pause Video" but aria-pressed is set to "false". For toggle buttons, aria-pressed should represent the current on/off state; this should be consistent with playback (playing => aria-pressed="true", paused => aria-pressed="false").

Copilot uses AI. Check for mistakes.
const btn = parentContainer.querySelector('.az-video-playpause');
if (btn) {
btn.textContent = 'Play Video';
btn.setAttribute('aria-pressed', 'true');
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

In the paused state (event.data === 2), the button is set to "Play Video" but aria-pressed is set to "true", which reports the toggle as pressed while the video is paused. Align aria-pressed with the playback state (paused => aria-pressed="false") to keep the programmatic state accurate.

Suggested change
btn.setAttribute('aria-pressed', 'true');
btn.setAttribute('aria-pressed', 'false');

Copilot uses AI. Check for mistakes.
Comment on lines +97 to 111
// Sync button and class state when video plays.
element.player.on('play', () => {
parentParagraph.classList.remove('az-video-paused');
parentParagraph.classList.add('az-video-playing');
playPauseButton.textContent = 'Pause Video';
playPauseButton.setAttribute('aria-pressed', 'false');
});

// Sync button and class state when video pauses.
element.player.on('pause', () => {
parentParagraph.classList.remove('az-video-playing');
parentParagraph.classList.add('az-video-paused');
playPauseButton.textContent = 'Play Video';
playPauseButton.setAttribute('aria-pressed', 'true');
});
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

The Vimeo play/pause handlers update the label, but the aria-pressed values are inverted relative to playback (playing sets aria-pressed to "false" and paused sets it to "true"). For a toggle button, aria-pressed should reflect the current state (playing => true, paused => false) so assistive tech gets an accurate programmatic value.

Copilot uses AI. Check for mistakes.
Comment on lines +121 to 128
// Play/Pause button: delegate state changes to player events.
playPauseButton.addEventListener('click', (event) => {
event.preventDefault();
if (event.currentTarget.textContent === 'Play Video') {
if (event.currentTarget.getAttribute('aria-pressed') === 'true') {
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');
}
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

The click handler treats aria-pressed="true" as "paused" (it calls player.play()), which inverts toggle semantics. After aligning aria-pressed with playback state, update this conditional so the action is derived from the current playback state consistently.

Copilot uses AI. Check for mistakes.
<div class="az-video-player"></div>
</div>
<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" aria-pressed="true">Play Video</button>
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

The initial state sets aria-pressed="true" while the label is "Play Video" (and playback is not yet confirmed). For a toggle button, aria-pressed should reflect the current state; starting from an unknown/not-playing state should be aria-pressed="false", then updated to true only once playback is confirmed.

Suggested change
<button type="button" class="bg-video-player-control btn btn-light az-video-playpause video-playpause" aria-pressed="true">Play Video</button>
<button type="button" class="bg-video-player-control btn btn-light az-video-playpause video-playpause" aria-pressed="false">Play Video</button>

Copilot uses AI. Check for mistakes.
Comment on lines +55 to 66
// Play/Pause button: delegate state changes to player events.
const playPauseButton =
element.getElementsByClassName('az-video-playpause')[0];
playPauseButton.addEventListener('click', (event) => {
event.preventDefault();
if (event.currentTarget.textContent === 'Play Video') {
if (
event.currentTarget.getAttribute('aria-pressed') === 'true'
) {
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');
}
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

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

The click handler treats aria-pressed="true" as the paused state (it calls playVideo()), which inverts typical toggle semantics and makes aria-pressed represent the opposite of the actual playback state. Consider making aria-pressed="true" mean "video is playing" and adjusting the click logic accordingly so the ARIA state matches reality.

Copilot uses AI. Check for mistakes.
- template: fix initial aria-pressed from 'true' to 'false' (video not yet playing on load)
- youtube.js: invert click handler so aria-pressed='true' (playing) calls pauseVideo()
- youtube.js: add classList.remove('az-video-paused') when entering playing state
- youtube.js: set aria-pressed='true' in playing state, 'false' in paused state
- vimeo.js: set aria-pressed='true' in play event, 'false' in pause event
- vimeo.js: invert click handler so aria-pressed='true' (playing) calls pause()

All changes align aria-pressed semantics: true = video is playing,
false = video is not playing, matching the ARIA toggle button pattern.

Addresses Copilot review on #5516.
@accesswatch
Copy link
Copy Markdown
Contributor Author

All 7 Copilot review comments addressed in commit 7c2d422:

  • template: initial \�ria-pressed\ corrected from \ rue\ to \ alse\ — video not yet confirmed playing on load
  • youtube.js: click handler inverted so \�ria-pressed='true'\ (playing) calls \pauseVideo()\
  • youtube.js: \classList.remove('az-video-paused')\ added when entering playing state to prevent both classes coexisting
  • youtube.js: \onPlayerStateChange\ state 1 now sets \�ria-pressed='true', state 2 sets 'false'\
  • vimeo.js: \play\ event now sets \�ria-pressed='true', \pause\ event sets 'false'\
  • vimeo.js: click handler inverted so \�ria-pressed='true'\ (playing) calls \pause()\

\�ria-pressed\ semantics are now consistent across both players: \ rue\ = video is playing, \ alse\ = video is not playing. Ready for re-review.

@accesswatch accesswatch requested a review from joshuasosa May 1, 2026 19:39
@joshuasosa joshuasosa merged commit 691c1e8 into issue/5490 May 5, 2026
32 checks passed
@joshuasosa joshuasosa deleted the a11y/5515-video-button-aria-pressed branch May 5, 2026 19:01
Sign up for free to 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.

3 participants