Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions js/mediaView.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,20 @@ class MediaView extends ComponentView {
});
this.$('[aria-controls]').removeAttr('aria-controls');
this.$('.mejs-overlay-play').attr('aria-hidden', 'true');

const $captionsButton = this.$('.mejs-captions-button button');
if ($captionsButton.length) {
// Re-add aria-controls for captions button if it was removed above
const mediaId = this.$('.mejs-container').attr('id');
if (mediaId) {
$captionsButton.attr('aria-controls', mediaId);
}

const $selector = this.$('.mejs-captions-selector');
if ($selector.length && !$selector.attr('aria-hidden')) {
$selector.attr('aria-hidden', 'true').css('display', 'none');
}
}
}

setupEventListeners() {
Expand Down Expand Up @@ -219,7 +233,7 @@ class MediaView extends ComponentView {
'.mejs-captions-button button' :
'.mejs-captions-selector';

this.$(selector).on('click.mediaCaptionsChange', _.debounce(() => {
this.$(selector).on('change.mediaCaptionsChange', _.debounce(() => {
const srclang = this.mediaElement.player.selectedTrack ? this.mediaElement.player.selectedTrack.srclang : 'none';
offlineStorage.set('captions', srclang);
Adapt.trigger('media:captionsChange', this, srclang);
Expand All @@ -243,8 +257,8 @@ class MediaView extends ComponentView {

// because calling player.setTrack doesn't update the cc button's languages popup...
const $inputs = this.$('.mejs-captions-selector input');
$inputs.filter(':checked').prop('checked', false);
$inputs.filter(`[value="${lang}"]`).prop('checked', true);
$inputs.filter(':checked').prop('checked', false).attr('aria-checked', 'false');
$inputs.filter(`[value="${lang}"]`).prop('checked', true).attr('aria-checked', 'true');
}

/**
Expand Down Expand Up @@ -378,7 +392,7 @@ class MediaView extends ComponentView {
const selector = this.model.get('_playerOptions').toggleCaptionsButtonWhenOnlyOne ?
'.mejs-captions-button button' :
'.mejs-captions-selector';
this.$(selector).off('click.mediaCaptionsChange');
this.$(selector).off('change.mediaCaptionsChange');
}

const modelOptions = this.model.get('_playerOptions');
Expand Down
162 changes: 161 additions & 1 deletion less/mep-overrides.less
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,43 @@
background-image: url(assets/background.png);
}

// Override default caption selector visibility behavior for better accessibility
.mejs-controls .mejs-captions-button {
.mejs-captions-selector {
visibility: visible;

&[style*="display: block"] {
visibility: visible;
}

&[style*="display: none"] {
Copy link
Member

@oliverfoster oliverfoster Aug 29, 2025

Choose a reason for hiding this comment

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

If an element is display: none it is already visibility: hidden. I don't understand what any of this code is for. Could you clarify please?

visibility: hidden;
}
}

// Improve focus visibility for the captions button
button {
&:focus {
outline: 2px solid #fff;
outline-offset: 2px;
background-color: rgba(255, 255, 255, 0.2);
}

&:focus:not(:focus-visible) {
// Hide focus outline for mouse users on browsers that support :focus-visible
outline: none;
background-color: transparent;
}

&:focus-visible {
// Show focus outline only for keyboard users
outline: 2px solid #fff;
outline-offset: 2px;
background-color: rgba(255, 255, 255, 0.2);
}
}
}

.mejs-overlay-loading span {
background-image: url(assets/loading.gif);
}
Expand Down Expand Up @@ -76,9 +113,132 @@
object-fit: contain;
}

// Accessibility improvements for captions selector focus indicators
// --------------------------------------------------
.mejs-controls .mejs-captions-button .mejs-captions-selector {
// Ensure the selector container is properly focusable
&[style*="display: block"] {
z-index: 9999 !important;
}

ul {
// Ensure proper focus ring spacing
padding: 4px !important;

li {
// Ensure proper positioning for focus outline
position: relative;
margin: 2px 0;
padding: 1px;
}

input[type="radio"] {
// Ensure radio buttons are focusable and visible
// Force radio buttons to be visible for focus
position: relative !important;
opacity: 1 !important;
clip: auto !important;
width: auto !important;
height: auto !important;
margin: 4px 8px 4px 4px !important;

&:focus {
outline: 3px solid #fff !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5),
0 0 8px rgba(255, 255, 255, 0.6) !important;
background-color: rgba(255, 255, 255, 0.2) !important;
}

// Hide focus outline for mouse users on browsers that support :focus-visible
&:focus:not(:focus-visible) {
outline: none !important;
box-shadow: none !important;
background-color: transparent !important;
}

// Show focus outline only for keyboard users
&:focus-visible {
outline: 3px solid #fff !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5),
0 0 8px rgba(255, 255, 255, 0.6) !important;
background-color: rgba(255, 255, 255, 0.2) !important;
}

// Show checked state more clearly with focus
&:checked {
// Add a visible indicator for checked state
&::after {
content: "";
position: absolute;
left: 8px;
top: 6px;
width: 4px;
height: 4px;
background-color: #fff;
border-radius: 50%;
}
}

// Combined focus and checked state
&:focus:checked,
&:focus-visible:checked {
outline: 3px solid #ffff00 !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.7),
0 0 12px rgba(255, 255, 0, 0.8) !important;
background-color: rgba(255, 255, 0, 0.15) !important;
}
}

label {
// Ensure labels have proper interactive styling
cursor: pointer;
transition: background-color 0.15s ease;
border-radius: 2px;
padding: 2px 4px;
display: block;

// Focus styling for when label receives focus via radio button
input[type="radio"]:focus + &,
input[type="radio"]:focus-visible + & {
background-color: rgba(255, 255, 255, 0.25) !important;
text-shadow: 0 0 3px rgba(0, 0, 0, 0.9) !important;
border: 1px solid rgba(255, 255, 255, 0.5) !important;
outline: 1px solid #fff !important;
outline-offset: 1px !important;
}

// Checked state styling
input[type="radio"]:checked + & {
font-weight: bold;
background-color: rgba(255, 255, 255, 0.15);
color: #fff;
}

// Combined checked and focused state
input[type="radio"]:focus:checked + &,
input[type="radio"]:focus-visible:checked + & {
background-color: rgba(255, 255, 0, 0.2) !important;
color: #fff !important;
font-weight: bold;
border: 1px solid rgba(255, 255, 0, 0.7) !important;
outline: 1px solid #ffff00 !important;
}

// Hover state
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
}

// Bug fix for missing control icons on iOS touch devices
// required for Safari/Chrome iOS 11. Ref: https://caniuse.com/#feat=css-clip-path
// --------------------------------------------------
.mejs-offscreen {
-webkit-clip-path: polygon(0 0, 0 0,0 0, 0 0);
-webkit-clip-path: polygon(0 0, 0 0, 0 0, 0 0);
clip-path: polygon(0 0, 0 0, 0 0, 0 0);
}
118 changes: 99 additions & 19 deletions libraries/mediaelement-and-player.js
Original file line number Diff line number Diff line change
Expand Up @@ -4895,11 +4895,11 @@ if (typeof jQuery != 'undefined') {
player.captionsText = player.captions.find('.mejs-captions-text');
player.captionsButton =
$('<div class="mejs-button mejs-captions-button">' +
'<button type="button" aria-controls="' + t.id + '" title="' + t.options.tracksText + '" aria-label="' + t.options.tracksText + '"></button>' +
'<div class="mejs-captions-selector">' +
'<ul>' +
'<li>' +
'<input type="radio" name="' + player.id + '_captions" id="' + player.id + '_captions_none" value="none" checked="checked" />' +
'<button type="button" aria-controls="' + t.id + '" title="' + t.options.tracksText + '" aria-label="' + t.options.tracksText + '" aria-haspopup="true" aria-expanded="false"></button>' +
'<div class="mejs-captions-selector" role="menu" aria-hidden="true" style="display: none;">' +
'<ul role="none">' +
'<li role="none">' +
'<input type="radio" name="' + player.id + '_captions" id="' + player.id + '_captions_none" value="none" checked="checked" role="menuitemradio" aria-checked="true" />' +
'<label for="' + player.id + '_captions_none">' + mejs.i18n.t('None') + '</label>' +
'</li>' +
'</ul>' +
Expand Down Expand Up @@ -4927,19 +4927,93 @@ if (typeof jQuery != 'undefined') {
player.setTrack(lang);
});
} else {
// hover or keyboard focus
player.captionsButton.on('mouseenter focusin', function () {
$(this).find('.mejs-captions-selector').removeClass('mejs-offscreen');
})
// Proper accessibility implementation for captions dropdown
var $button = player.captionsButton.find('button');
var $selector = player.captionsButton.find('.mejs-captions-selector');
var isOpen = false;

// handle clicks to the language radio buttons
.on('click', 'input[type=radio]', function () {
lang = this.value;
player.setTrack(lang);
});
$button.on('click', function(e) {
e.preventDefault();
isOpen = !isOpen;

player.captionsButton.on('mouseleave focusout', function () {
$(this).find(".mejs-captions-selector").addClass("mejs-offscreen");
if (isOpen) {
$selector.css('display', 'block').attr('aria-hidden', 'false');
$button.attr('aria-expanded', 'true');
$selector.find('input[type=radio]').first().focus();
} else {
$selector.css('display', 'none').attr('aria-hidden', 'true');
$button.attr('aria-expanded', 'false');
}
});

$selector.on('keydown', 'input[type=radio]', function(e) {
var $radios = $selector.find('input[type=radio]');
var currentIndex = $radios.index(this);

switch(e.keyCode) {
case 27:
e.preventDefault();
isOpen = false;
$selector.css('display', 'none').attr('aria-hidden', 'true');
$button.attr('aria-expanded', 'false').focus();
break;
case 38:
e.preventDefault();
if (currentIndex > 0) {
$radios.eq(currentIndex - 1).focus();
}
break;
case 40:
e.preventDefault();
if (currentIndex < $radios.length - 1) {
$radios.eq(currentIndex + 1).focus();
}
break;
case 13:
case 32:
e.preventDefault();
$(this).prop('checked', true).trigger('change');
break;
}
});

player.captionsButton.on('change', 'input[type=radio]', function () {
lang = this.value;
player.setTrack(lang);

var $radios = $selector.find('input[type=radio]');
$radios.attr('aria-checked', 'false');
$(this).attr('aria-checked', 'true');

isOpen = false;
$selector.css('display', 'none').attr('aria-hidden', 'true');
$button.attr('aria-expanded', 'false').focus();
});

$(document).on('click', function(e) {
if (isOpen && !player.captionsButton.is(e.target) && player.captionsButton.has(e.target).length === 0) {
isOpen = false;
$selector.css('display', 'none').attr('aria-hidden', 'true');
$button.attr('aria-expanded', 'false');
}
});

player.captionsButton.on('mouseenter', function () {
if (!isOpen) {
isOpen = true;
$selector.css('display', 'block').attr('aria-hidden', 'false');
$button.attr('aria-expanded', 'true');
}
});

player.captionsButton.on('mouseleave', function () {
setTimeout(function() {
if (isOpen && !$selector.is(':hover') && !$button.is(':focus') && !$selector.find(':focus').length) {
isOpen = false;
$selector.css('display', 'none').attr('aria-hidden', 'true');
$button.attr('aria-expanded', 'false');
}
}, 100);
});

}
Expand Down Expand Up @@ -5025,6 +5099,11 @@ if (typeof jQuery != 'undefined') {
var t = this,
i;

t.captionsButton.find('input[type=radio]').each(function() {
var isChecked = $(this).val() === lang;
$(this).prop('checked', isChecked).attr('aria-checked', isChecked ? 'true' : 'false');
});

if (lang == 'none') {
t.selectedTrack = null;
t.captionsButton.removeClass('mejs-captions-enabled');
Expand Down Expand Up @@ -5115,12 +5194,13 @@ if (typeof jQuery != 'undefined') {
t.captionsButton
.find('input[value=' + lang + ']')
.prop('disabled', false)
.attr('aria-disabled', 'false')
.siblings('label')
.html(label);

// auto select
if (t.options.startLanguage == lang) {
$('#' + t.id + '_captions_' + lang).prop('checked', true).trigger('click');
$('#' + t.id + '_captions_' + lang).prop('checked', true).attr('aria-checked', 'true').trigger('click');
}

t.adjustLanguageBox();
Expand All @@ -5141,8 +5221,8 @@ if (typeof jQuery != 'undefined') {
}

t.captionsButton.find('ul').append(
$('<li>' +
'<input type="radio" name="' + t.id + '_captions" id="' + t.id + '_captions_' + lang + '" value="' + lang + '" disabled="disabled" />' +
$('<li role="none">' +
'<input type="radio" name="' + t.id + '_captions" id="' + t.id + '_captions_' + lang + '" value="' + lang + '" disabled="disabled" role="menuitemradio" aria-checked="false" />' +
'<label for="' + t.id + '_captions_' + lang + '">' + label + ' (loading)' + '</label>' +
'</li>')
);
Expand Down