diff --git a/core/modules/media_library/css/media_library.theme.css b/core/modules/media_library/css/media_library.theme.css index 0e066d8887483d71fc5bf9350404f3d0298ed011..baf0704167e3c06b58f000a3f30ee441ae292dca 100644 --- a/core/modules/media_library/css/media_library.theme.css +++ b/core/modules/media_library/css/media_library.theme.css @@ -435,6 +435,7 @@ * seven theme in https://www.drupal.org/project/drupal/issues/2980769 */ .button.media-library-open-button { + margin-bottom: 1em; margin-left: 0; /* LTR */ } [dir="rtl"] .button.media-library-open-button { diff --git a/core/modules/media_library/js/media_library.widget.es6.js b/core/modules/media_library/js/media_library.widget.es6.js index 93878a0f6a34297ae7eba7104e04fb84fae67460..b34a863d15fbaea2f1c30fa98edbd28cd1fb98c8 100644 --- a/core/modules/media_library/js/media_library.widget.es6.js +++ b/core/modules/media_library/js/media_library.widget.es6.js @@ -70,4 +70,34 @@ .hide(); }, }; + + /** + * Disable the open button when the user is not allowed to add more items. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior to disable the media library open button. + */ + Drupal.behaviors.MediaLibraryWidgetDisableButton = { + attach(context) { + // When the user returns from the modal to the widget, we want to shift + // the focus back to the open button. If the user is not allowed to add + // more items, the button needs to be disabled. Since we can't shift the + // focus to disabled elements, the focus is set back to the open button + // via JavaScript by adding the 'data-disabled-focus' attribute. + $('.js-media-library-open-button[data-disabled-focus="true"]', context) + .once('media-library-disable') + .each(function() { + $(this).focus(); + + // There is a small delay between the focus set by the browser and the + // focus of screen readers. We need to give screen readers time to + // shift the focus as well before the button is disabled. + setTimeout(() => { + $(this).attr('disabled', 'disabled'); + }, 50); + }); + }, + }; })(jQuery, Drupal); diff --git a/core/modules/media_library/js/media_library.widget.js b/core/modules/media_library/js/media_library.widget.js index f2fcf82b3b0150629a0b835680f85d6b08ec000b..8194fe26595868c22926ef568106d79fde0319fd 100644 --- a/core/modules/media_library/js/media_library.widget.js +++ b/core/modules/media_library/js/media_library.widget.js @@ -36,4 +36,18 @@ $('.js-media-library-item-weight', context).once('media-library-toggle').parent().hide(); } }; + + Drupal.behaviors.MediaLibraryWidgetDisableButton = { + attach: function attach(context) { + $('.js-media-library-open-button[data-disabled-focus="true"]', context).once('media-library-disable').each(function () { + var _this = this; + + $(this).focus(); + + setTimeout(function () { + $(_this).attr('disabled', 'disabled'); + }, 50); + }); + } + }; })(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php b/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php index 54adfa9c802d3d6a599d1805f3798f40ef12848d..031234e2cdbba67ffef7a3a58f96e5852c9b8076 100644 --- a/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php +++ b/core/modules/media_library/src/Plugin/Field/FieldWidget/MediaLibraryWidget.php @@ -5,7 +5,9 @@ use Drupal\Component\Utility\NestedArray; use Drupal\Component\Utility\SortArray; use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\InvokeCommand; use Drupal\Core\Ajax\OpenModalDialogCommand; +use Drupal\Core\Ajax\ReplaceCommand; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemListInterface; @@ -383,7 +385,12 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen else { $cardinality_message = $this->t('The maximum number of media items have been selected.'); } - $element['#description'] .= '<br />' . $cardinality_message; + + // Add a line break between the field message and the cardinality message. + if (!empty($element['#description'])) { + $element['#description'] .= '<br />'; + } + $element['#description'] .= $cardinality_message; } // Create a new media library URL with the correct state parameters. @@ -421,9 +428,19 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen '#submit' => [], // Allow the media library to be opened even if there are form errors. '#limit_validation_errors' => [], - '#access' => $cardinality_unlimited || $remaining > 0, ]; + // When the user returns from the modal to the widget, we want to shift the + // focus back to the open button. If the user is not allowed to add more + // items, the button needs to be disabled. Since we can't shift the focus to + // disabled elements, the focus is set back to the open button via + // JavaScript by adding the 'data-disabled-focus' attribute. + // @see Drupal.behaviors.MediaLibraryWidgetDisableButton + if (!$cardinality_unlimited && $remaining === 0) { + $element['media_library_open_button']['#attributes']['data-disabled-focus'] = 'true'; + $element['media_library_open_button']['#attributes']['class'][] = 'visually-hidden'; + } + // This hidden field and button are used to add new items to the widget. $element['media_library_selection'] = [ '#type' => 'hidden', @@ -482,14 +499,17 @@ public function massageFormValues(array $values, array $form, FormStateInterface * @param \Drupal\Core\Form\FormStateInterface $form_state * The form state. * - * @return array - * An array representing the updated widget. + * @return \Drupal\Core\Ajax\AjaxResponse + * An AJAX response to update the selection. */ public static function updateWidget(array $form, FormStateInterface $form_state) { $triggering_element = $form_state->getTriggeringElement(); + $wrapper_id = $triggering_element['#ajax']['wrapper']; + // This callback is either invoked from the remove button or the update // button, which have different nesting levels. - $length = end($triggering_element['#parents']) === 'remove_button' ? -4 : -1; + $remove_button = end($triggering_element['#parents']) === 'remove_button'; + $length = $remove_button ? -4 : -1; if (count($triggering_element['#array_parents']) < abs($length)) { throw new \LogicException('The element that triggered the widget update was at an unexpected depth. Triggering element parents were: ' . implode(',', $triggering_element['#array_parents'])); } @@ -497,7 +517,30 @@ public static function updateWidget(array $form, FormStateInterface $form_state) $element = NestedArray::getValue($form, $parents); // Always clear the textfield selection to prevent duplicate additions. $element['media_library_selection']['#value'] = ''; - return $element; + + $response = new AjaxResponse(); + $response->addCommand(new ReplaceCommand("#$wrapper_id", $element)); + + $field_state = static::getFieldState($element, $form_state); + + // When the remove button is clicked, the focus will be kept in the + // selection area by default. When the last item is deleted, we no longer + // have a selection and shift the focus to the open button. + $removed_last = $remove_button && !count($field_state['items']); + + // Shift focus to the open button if the user did not click the remove + // button. When the user is not allowed to add more items, the button needs + // to be disabled. Since we can't shift the focus to disabled elements, the + // focus is set via JavaScript by adding the 'data-disabled-focus' attribute + // and we also don't want to set the focus here. + // @see Drupal.behaviors.MediaLibraryWidgetDisableButton + $select_more = !$remove_button && !isset($element['media_library_open_button']['#attributes']['data-disabled-focus']); + + if ($removed_last || $select_more) { + $response->addCommand(new InvokeCommand("#$wrapper_id .js-media-library-open-button", 'focus')); + } + + return $response; } /** diff --git a/core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php b/core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php index c2067660e8231efa790d9c806ee3b46be5afa768..311151b626ebc406be5dba6cbdfe05a433127863 100644 --- a/core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php +++ b/core/modules/media_library/src/Plugin/views/field/MediaLibrarySelectForm.php @@ -99,8 +99,17 @@ public function viewsForm(array &$form, FormStateInterface $form_state) { $form['actions']['submit']['#value'] = $this->t('Select media'); $form['actions']['submit']['#field_id'] = $selection_field_id; + // By default, the AJAX system tries to move the focus back to the element + // that triggered the AJAX request. Since the media library is closed after + // clicking the select button, the focus can't be moved back. We need to set + // the 'data-disable-refocus' attribute to prevent the AJAX system from + // moving focus to a random element. The select button triggers an update in + // the opener, and the opener should be responsible for moving the focus. An + // example of this can be seen in MediaLibraryWidget::updateWidget(). + // @see \Drupal\media_library\Plugin\Field\FieldWidget\MediaLibraryWidget::updateWidget() $form['actions']['submit']['#attributes'] = [ 'class' => ['media-library-select'], + 'data-disable-refocus' => 'true', ]; } diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php index cbba55dc81e4c4d5e4915e34951e775f56acb968..a2a9b75b9e1a3101ad33cc7060b06c9c09ae2276 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php @@ -242,6 +242,7 @@ public function testWidget() { // Select a media item, assert the hidden selection field contains the ID of // the selected item. $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); + $this->assertGreaterThanOrEqual(1, count($checkboxes)); $checkboxes[0]->click(); $assert_session->hiddenFieldValueEquals('media-library-modal-selection', '4'); $assert_session->elementTextContains('css', '.media-library-selected-count', '1 of 1 item selected'); @@ -366,6 +367,22 @@ public function testWidget() { $assert_session->elementExists('css', '.media-library-item__remove')->click(); $assert_session->assertWaitOnAjaxRequest(); + // Assert adding a single media item and removing it. + $assert_session->elementExists('css', '.media-library-open-button[name^="field_twin_media"]')->click(); + $assert_session->assertWaitOnAjaxRequest(); + $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); + $this->assertGreaterThanOrEqual(1, count($checkboxes)); + $checkboxes[0]->click(); + $assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Select media'); + $assert_session->assertWaitOnAjaxRequest(); + // Assert the focus is set back on the open button of the media field. + $this->assertJsCondition('jQuery("#field_twin_media-media-library-wrapper .js-media-library-open-button").is(":focus")'); + $assert_session->elementAttributeContains('css', '.media-library-item__remove', 'aria-label', 'Remove Dog'); + $assert_session->elementExists('css', '.media-library-item__remove')->click(); + $assert_session->assertWaitOnAjaxRequest(); + // Assert the focus is set back on the open button of the media field. + $this->assertJsCondition('jQuery("#field_twin_media-media-library-wrapper .js-media-library-open-button").is(":focus")'); + // Assert the selection is persistent in the media library modal, and // the number of selected items is displayed correctly. $assert_session->elementExists('css', '.media-library-open-button[name^="field_twin_media"]')->click(); @@ -377,6 +394,7 @@ public function testWidget() { // Select a media item, assert the hidden selection field contains the ID of // the selected item. $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); + $this->assertCount(4, $checkboxes); $checkboxes[0]->click(); $assert_session->hiddenFieldValueEquals('media-library-modal-selection', '4'); // Assert the number of selected items is displayed correctly. @@ -416,6 +434,7 @@ public function testWidget() { $page->clickLink('Type Two'); $assert_session->assertWaitOnAjaxRequest(); $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); + $this->assertCount(4, $checkboxes); $checkboxes[0]->click(); // Assert the selection is updated correctly. $assert_session->elementTextContains('css', '.media-library-selected-count', '2 of 2 items selected'); @@ -436,6 +455,11 @@ public function testWidget() { // Select the items. $assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Select media'); $assert_session->assertWaitOnAjaxRequest(); + // Assert the open button is disabled. + $open_button = $assert_session->elementExists('css', '.media-library-open-button[name^="field_twin_media"]'); + $this->assertTrue($open_button->hasAttribute('data-disabled-focus')); + $this->assertTrue($open_button->hasAttribute('disabled')); + $this->assertJsCondition('jQuery("#field_twin_media-media-library-wrapper .media-library-open-button").is(":disabled")'); // Ensure that the selection completed successfully. $assert_session->pageTextNotContains('Add or select media'); @@ -448,13 +472,21 @@ public function testWidget() { $assert_session->elementAttributeContains('css', '.media-library-item__remove', 'aria-label', 'Remove Cat'); $assert_session->elementExists('css', '.media-library-item__remove')->click(); $assert_session->assertWaitOnAjaxRequest(); + // Assert the focus is set to the remove button of the other selected item. + $this->assertJsCondition('jQuery("#field_twin_media-media-library-wrapper .media-library-item__remove").is(":focus")'); $assert_session->pageTextNotContains('Cat'); $assert_session->pageTextContains('Turtle'); + // Assert the open button is no longer disabled. + $open_button = $assert_session->elementExists('css', '.media-library-open-button[name^="field_twin_media"]'); + $this->assertFalse($open_button->hasAttribute('data-disabled-focus')); + $this->assertFalse($open_button->hasAttribute('disabled')); + $this->assertJsCondition('jQuery("#field_twin_media-media-library-wrapper .media-library-open-button").is(":not(:disabled)")'); // Open the media library again and select another item. $assert_session->elementExists('css', '.media-library-open-button[name^="field_twin_media"]')->click(); $assert_session->assertWaitOnAjaxRequest(); $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); + $this->assertGreaterThanOrEqual(1, count($checkboxes)); $checkboxes[0]->click(); $assert_session->elementExists('css', '.ui-dialog-buttonpane')->pressButton('Select media'); $assert_session->assertWaitOnAjaxRequest(); @@ -462,13 +494,16 @@ public function testWidget() { $assert_session->pageTextNotContains('Cat'); $assert_session->pageTextContains('Turtle'); $assert_session->pageTextNotContains('Snake'); - - // Assert we are not allowed to add more items to the field. - $assert_session->elementNotExists('css', '.media-library-open-button[name^="field_twin_media"]'); + // Assert the open button is disabled. + $this->assertTrue($assert_session->elementExists('css', '.media-library-open-button[name^="field_twin_media"]')->hasAttribute('data-disabled-focus')); + $this->assertTrue($assert_session->elementExists('css', '.media-library-open-button[name^="field_twin_media"]')->hasAttribute('disabled')); + $this->assertJsCondition('jQuery("#field_twin_media-media-library-wrapper .media-library-open-button").is(":disabled")'); // Assert the selection is cleared when the modal is closed. $assert_session->elementExists('css', '.media-library-open-button[name^="field_unlimited_media"]')->click(); $assert_session->assertWaitOnAjaxRequest(); + $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); + $this->assertGreaterThanOrEqual(4, count($checkboxes)); // Nothing is selected yet. $this->assertFalse($checkboxes[0]->isChecked()); $this->assertFalse($checkboxes[1]->isChecked()); @@ -476,7 +511,6 @@ public function testWidget() { $this->assertFalse($checkboxes[3]->isChecked()); $assert_session->elementTextContains('css', '.media-library-selected-count', '0 items selected'); // Select the first 2 items. - $checkboxes = $page->findAll('css', '.media-library-view .js-click-to-select-checkbox input'); $checkboxes[0]->click(); $assert_session->elementTextContains('css', '.media-library-selected-count', '1 item selected'); $checkboxes[1]->click(); @@ -527,6 +561,7 @@ public function testWidget() { // Select all media items of type one (should also contain Dog, again). $checkbox_selector = '.media-library-view .js-click-to-select-checkbox input'; $checkboxes = $page->findAll('css', $checkbox_selector); + $this->assertGreaterThanOrEqual(4, count($checkboxes)); $checkboxes[0]->click(); $checkboxes[1]->click(); $checkboxes[2]->click();