From d757e2c571b2bf43034415664e885942a07512ab Mon Sep 17 00:00:00 2001 From: Cristina Chumillas <ckrina@1206650.no-reply.drupal.org> Date: Wed, 28 Dec 2022 16:06:20 +0100 Subject: [PATCH] Issue #3070558 by bnjmnm, lauriii, huzooka, mherchel, katherined, kostyashupenko, rkoller, saschaeggi, andrewmacpherson, ckrina, dww, smustgrave, mgifford, klonos, shaal, andypost: Implement bulk operation designs --- core/misc/cspell/dictionary.txt | 2 + .../Theme/ClaroViewsBulkOperationsTest.php | 102 +++++ core/themes/claro/claro.info.yml | 2 + core/themes/claro/claro.libraries.yml | 12 + core/themes/claro/claro.theme | 81 ++++ core/themes/claro/css/base/variables.css | 1 + core/themes/claro/css/base/variables.pcss.css | 1 + .../claro/css/components/dropbutton.css | 2 +- .../claro/css/components/dropbutton.pcss.css | 2 +- .../claro/css/components/tableselect.css | 111 +++++- .../claro/css/components/tableselect.pcss.css | 101 ++++- core/themes/claro/js/tableselect.js | 374 ++++++++++++++++++ 12 files changed, 787 insertions(+), 4 deletions(-) create mode 100644 core/tests/Drupal/FunctionalJavascriptTests/Theme/ClaroViewsBulkOperationsTest.php create mode 100644 core/themes/claro/js/tableselect.js diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 55be59f85209..0fb79e9f636e 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -1121,6 +1121,7 @@ starterkit starzzzz statuscode stdclass +stickied stitle streamwrapper streamwrappers @@ -1326,6 +1327,7 @@ unaliased unallowed unassigning unassigns +unhides unban unbans unbundleable diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Theme/ClaroViewsBulkOperationsTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Theme/ClaroViewsBulkOperationsTest.php new file mode 100644 index 000000000000..bca5d973abab --- /dev/null +++ b/core/tests/Drupal/FunctionalJavascriptTests/Theme/ClaroViewsBulkOperationsTest.php @@ -0,0 +1,102 @@ +<?php + +namespace Drupal\FunctionalJavascriptTests\Theme; + +use Drupal\FunctionalJavascriptTests\WebDriverTestBase; +use Drupal\Tests\node\Traits\ContentTypeCreationTrait; +use Drupal\Tests\node\Traits\NodeCreationTrait; + +/** + * Tests Claro's Views Bulk Operations form. + * + * @group claro + */ +class ClaroViewsBulkOperationsTest extends WebDriverTestBase { + use ContentTypeCreationTrait; + use NodeCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = ['node', 'views']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'claro'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Create a Content type and two test nodes. + $this->createContentType(['type' => 'page']); + $this->createNode(['title' => 'Page One']); + $this->createNode(['title' => 'Page Two']); + + // Create a user privileged enough to use exposed filters and view content. + $user = $this->drupalCreateUser([ + 'administer site configuration', + 'access content', + 'access content overview', + 'edit any page content', + ]); + $this->drupalLogin($user); + } + + /** + * Tests the dynamic Bulk Operations form. + */ + public function testBulkOperationsUi() { + $this->drupalGet('admin/content'); + + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + + $no_items_selected = 'No items selected'; + $one_item_selected = '1 item selected'; + $two_items_selected = '2 items selected'; + $vbo_available_message = 'Bulk actions are now available'; + $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")")); + $select_all = $page->find('css', '.select-all > input'); + + $page->checkField('node_bulk_form[0]'); + $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$one_item_selected\")")); + + // When the bulk operations controls are first activated, this should be + // relayed to screen readers. + $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$vbo_available_message\")")); + $this->assertFalse($select_all->isChecked()); + + $page->checkField('node_bulk_form[1]'); + $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$two_items_selected\")")); + $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$two_items_selected\")")); + $assert_session->pageTextNotContains($vbo_available_message); + $this->assertTrue($select_all->isChecked()); + + $page->uncheckField('node_bulk_form[0]'); + $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$one_item_selected\")")); + $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$one_item_selected\")")); + $assert_session->pageTextNotContains($vbo_available_message); + $this->assertFalse($select_all->isChecked()); + + $page->uncheckField('node_bulk_form[1]'); + $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")")); + $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$no_items_selected\")")); + $assert_session->pageTextNotContains($vbo_available_message); + $this->assertFalse($select_all->isChecked()); + + $select_all->check(); + $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$two_items_selected\")")); + $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$vbo_available_message\")")); + $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$two_items_selected\")")); + + $select_all->uncheck(); + $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")")); + $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$no_items_selected\")")); + $assert_session->pageTextNotContains($vbo_available_message); + } + +} diff --git a/core/themes/claro/claro.info.yml b/core/themes/claro/claro.info.yml index 365572376ae0..7853f5dbb2ad 100644 --- a/core/themes/claro/claro.info.yml +++ b/core/themes/claro/claro.info.yml @@ -122,6 +122,8 @@ libraries-extend: - claro/claro.jquery.ui core/drupal.tabledrag: - claro/claro.tabledrag + core/drupal.tableselect: + - claro/tableselect core/drupal.vertical-tabs: - claro/vertical-tabs file/drupal.file: diff --git a/core/themes/claro/claro.libraries.yml b/core/themes/claro/claro.libraries.yml index d417024b2c73..3a66b925320b 100644 --- a/core/themes/claro/claro.libraries.yml +++ b/core/themes/claro/claro.libraries.yml @@ -287,6 +287,18 @@ filter: component: css/theme/filter.theme.css: {} +tableselect: + version: VERSION + js: + js/tableselect.js: {} + dependencies: + - core/jquery + - core/drupal + - core/drupal.announce + - core/drupal.debounce + - core/tabbable + - core/once + classy.book-navigation: version: VERSION css: diff --git a/core/themes/claro/claro.theme b/core/themes/claro/claro.theme index 4b35d684a82a..143a9f4d0d76 100644 --- a/core/themes/claro/claro.theme +++ b/core/themes/claro/claro.theme @@ -408,6 +408,87 @@ function claro_form_alter(array &$form, FormStateInterface $form_state, $form_id $form['actions']['submit']['#attributes']['class'] = ['media-library-select']; $form['#attributes']['class'][] = 'media-library-views-form'; } + + if ($form_object instanceof ViewsForm && !empty($form['header'])) { + $view = $form_state->getBuildInfo()['args'][0]; + $view_title = $view->getTitle(); + + // Determine if the Views form includes a bulk operations form. If it does, + // move it to the bottom and remove the second bulk operations submit. + foreach (Element::children($form['header']) as $key) { + if (strpos($key, '_bulk_form') !== FALSE) { + // Move the bulk actions form from the header to its own container. + $form['bulk_actions_container'] = $form['header'][$key]; + unset($form['header'][$key]); + + // Remove the supplementary bulk operations submit button as it appears + // in the same location the form was moved to. + unset($form['actions']); + + $form['bulk_actions_container']['#attributes']['data-drupal-views-bulk-actions'] = ''; + $form['bulk_actions_container']['#attributes']['class'][] = 'views-bulk-actions'; + $form['bulk_actions_container']['actions']['submit']['#button_type'] = 'primary'; + $form['bulk_actions_container']['actions']['submit']['#attributes']['class'][] = 'button--small'; + $label = t('Perform actions on the selected items in the %view_title view', ['%view_title' => $view_title]); + $label_id = $key . '_group_label'; + + // Group the bulk actions select and submit elements, and add a label + // that makes the purpose of these elements more clear to + // screenreaders. + $form['bulk_actions_container']['#attributes']['role'] = 'group'; + $form['bulk_actions_container']['#attributes']['aria-labelledby'] = $label_id; + $form['bulk_actions_container']['group_label'] = [ + '#type' => 'container', + '#markup' => $label, + '#attributes' => [ + 'id' => $label_id, + 'class' => ['visually-hidden'], + ], + '#weight' => -1, + ]; + + // Add a status label for counting the number of items selected. + $form['bulk_actions_container']['status'] = [ + '#type' => 'container', + '#markup' => t('No items selected'), + '#weight' => -1, + '#attributes' => [ + 'class' => [ + 'js-views-bulk-actions-status', + 'views-bulk-actions__item', + 'views-bulk-actions__item--status', + 'js-show', + ], + 'data-drupal-views-bulk-actions-status' => '', + ], + ]; + + // Loop through bulk actions items and add the needed CSS classes. + $bulk_action_item_keys = Element::children($form['bulk_actions_container'], TRUE); + $bulk_last_key = NULL; + $bulk_child_before_actions_key = NULL; + foreach ($bulk_action_item_keys as $bulk_action_item_key) { + if (!empty($form['bulk_actions_container'][$bulk_action_item_key]['#type'])) { + if ($form['bulk_actions_container'][$bulk_action_item_key]['#type'] === 'actions') { + // We need the key of the element that precedes the actions + // element. + $bulk_child_before_actions_key = $bulk_last_key; + $form['bulk_actions_container'][$bulk_action_item_key]['#attributes']['class'][] = 'views-bulk-actions__item'; + } + + if (!in_array($form['bulk_actions_container'][$bulk_action_item_key]['#type'], ['hidden', 'actions'])) { + $form['bulk_actions_container'][$bulk_action_item_key]['#wrapper_attributes']['class'][] = 'views-bulk-actions__item'; + $bulk_last_key = $bulk_action_item_key; + } + } + } + + if ($bulk_child_before_actions_key) { + $form['bulk_actions_container'][$bulk_child_before_actions_key]['#wrapper_attributes']['class'][] = 'views-bulk-actions__item--preceding-actions'; + } + } + } + } } /** diff --git a/core/themes/claro/css/base/variables.css b/core/themes/claro/css/base/variables.css index bd525b89e2be..95b60a69e8b3 100644 --- a/core/themes/claro/css/base/variables.css +++ b/core/themes/claro/css/base/variables.css @@ -198,6 +198,7 @@ --button-bg-color--danger: var(--color-maximumred); --button--hover-bg-color--danger: var(--color-maximumred-hover); --button--active-bg-color--danger: var(--color-maximumred-active); + --dropbutton-widget-z-index: 100; /** * jQuery.UI dropdown. */ diff --git a/core/themes/claro/css/base/variables.pcss.css b/core/themes/claro/css/base/variables.pcss.css index 727fc94e35ce..d591c2283a2f 100644 --- a/core/themes/claro/css/base/variables.pcss.css +++ b/core/themes/claro/css/base/variables.pcss.css @@ -192,6 +192,7 @@ --button-bg-color--danger: var(--color-maximumred); --button--hover-bg-color--danger: var(--color-maximumred-hover); --button--active-bg-color--danger: var(--color-maximumred-active); + --dropbutton-widget-z-index: 100; /** * jQuery.UI dropdown. */ diff --git a/core/themes/claro/css/components/dropbutton.css b/core/themes/claro/css/components/dropbutton.css index c298c5bf33da..67213249147c 100644 --- a/core/themes/claro/css/components/dropbutton.css +++ b/core/themes/claro/css/components/dropbutton.css @@ -59,7 +59,7 @@ } .js .dropbutton-wrapper.open .dropbutton-widget { - z-index: 100; + z-index: var(--dropbutton-widget-z-index); } /** diff --git a/core/themes/claro/css/components/dropbutton.pcss.css b/core/themes/claro/css/components/dropbutton.pcss.css index 17b220aa0ff4..556577cf10b6 100644 --- a/core/themes/claro/css/components/dropbutton.pcss.css +++ b/core/themes/claro/css/components/dropbutton.pcss.css @@ -51,7 +51,7 @@ } .js .dropbutton-wrapper.open .dropbutton-widget { - z-index: 100; + z-index: var(--dropbutton-widget-z-index); } /** diff --git a/core/themes/claro/css/components/tableselect.css b/core/themes/claro/css/components/tableselect.css index edc01b5ae649..bc478b880a88 100644 --- a/core/themes/claro/css/components/tableselect.css +++ b/core/themes/claro/css/components/tableselect.css @@ -7,7 +7,7 @@ /** * @file - * Table select — replaces core implementation. + * Table select styles for Claro. * * @see tableselect.js */ @@ -26,3 +26,112 @@ th.checkbox { tr.selected td { background-color: var(--color-bgblue-active); } + +.views-bulk-actions { + position: relative; + display: flex; + flex: 1; + flex-wrap: wrap; + padding: var(--space-m) 2rem; + color: var(--color-white); + border: var(--details-border-size) solid var(--details-border-color); + border-radius: 0.25rem; + background-color: var(--color-text); +} + +.views-bulk-actions[data-drupal-sticky-vbo="true"] { + position: sticky; + z-index: calc(var(--dropbutton-widget-z-index) + 1); + bottom: var(--drupal-displace-offset-bottom, 0); + animation: fade-in-bottom 320ms 1 forwards; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +@supports (position: sticky) { + .views-bulk-actions[data-drupal-sticky-vbo="true"] { + position: sticky; + } +} + +@keyframes fade-in-bottom { + 0% { + transform: translateY(100%); + } + 100% { + transform: translateY(0); + } +} + +.views-bulk-actions.views-form__header--bypass-animation { + animation: none; +} + +.views-bulk-actions__item { + align-self: center; + margin-block: 0 var(--space-s); + margin-inline: 0 var(--space-l); +} + +.views-bulk-actions__item:last-of-type { + margin-inline-end: 0; +} + +.views-bulk-actions__item--status { + width: 100%; + white-space: nowrap; + font-size: var(--font-size-xs); + font-weight: bold; +} + +.views-bulk-actions__item input, +.views-bulk-actions__item .button { + margin-block: 0; +} + +.views-bulk-actions__item .form-element:hover { + border: var(--input-border-size) solid var(--input-border-color); + box-shadow: none; +} + +.views-bulk-actions__item .button--primary { + background: var(--color-blue-400); +} + +.views-bulk-actions__item .button--primary:hover { + background: var(--color-blue-500); +} + +.views-bulk-actions__item .form-item__label { + display: inline; + padding-inline-end: var(--space-xs); +} + +.views-bulk-actions__item .form-item__label:after { + content: ":"; +} + +.views-bulk-actions__item .form-element--type-select { + min-height: 2rem; + padding: calc(0.5rem - 1px) calc(2.25rem - 1px) calc(0.5rem - 1px) calc(1rem - 1px); + font-size: var(--font-size-xs); + line-height: 1rem; +} + +.views-field__skip-to-bulk-actions { + display: block; + white-space: nowrap; + font-size: var(--font-size-xs); +} + +@media screen and (min-width: 61rem) { + .views-bulk-actions { + flex-wrap: nowrap; + } + .views-bulk-actions__item { + margin-bottom: 0; + } + .views-bulk-actions__item--status { + width: auto; + } +} diff --git a/core/themes/claro/css/components/tableselect.pcss.css b/core/themes/claro/css/components/tableselect.pcss.css index 1d633fba2941..395c9e2ed5be 100644 --- a/core/themes/claro/css/components/tableselect.pcss.css +++ b/core/themes/claro/css/components/tableselect.pcss.css @@ -1,6 +1,6 @@ /** * @file - * Table select — replaces core implementation. + * Table select styles for Claro. * * @see tableselect.js */ @@ -18,3 +18,102 @@ th.checkbox { tr.selected td { background-color: var(--color-bgblue-active); } + +.views-bulk-actions { + position: relative; + display: flex; + flex: 1; + flex-wrap: wrap; + padding: var(--space-m) 2rem; + color: var(--color-white); + border: var(--details-border-size) solid var(--details-border-color); + border-radius: 4px; + background-color: var(--color-text); +} + +.views-bulk-actions[data-drupal-sticky-vbo="true"] { + position: sticky; + z-index: calc(var(--dropbutton-widget-z-index) + 1); + bottom: var(--drupal-displace-offset-bottom, 0); + animation: fade-in-bottom 320ms 1 forwards; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +@supports (position: sticky) { + .views-bulk-actions[data-drupal-sticky-vbo="true"] { + position: sticky; + } +} + +@keyframes fade-in-bottom { + 0% { + transform: translateY(100%); + } + 100% { + transform: translateY(0); + } +} +.views-bulk-actions.views-form__header--bypass-animation { + animation: none; +} + +.views-bulk-actions__item { + align-self: center; + margin-block: 0 var(--space-s); + margin-inline: 0 var(--space-l); +} +.views-bulk-actions__item:last-of-type { + margin-inline-end: 0; +} +.views-bulk-actions__item--status { + width: 100%; + white-space: nowrap; + font-size: var(--font-size-xs); + font-weight: bold; +} +.views-bulk-actions__item input, +.views-bulk-actions__item .button { + margin-block: 0; +} +.views-bulk-actions__item .form-element:hover { + border: var(--input-border-size) solid var(--input-border-color); + box-shadow: none; +} +.views-bulk-actions__item .button--primary { + background: var(--color-blue-400); + &:hover { + background: var(--color-blue-500); + } +} +.views-bulk-actions__item .form-item__label { + display: inline; + padding-inline-end: var(--space-xs); +} +.views-bulk-actions__item .form-item__label:after { + content: ":"; +} +.views-bulk-actions__item .form-element--type-select { + min-height: 2rem; + padding: calc(0.5rem - 1px) calc(2.25rem - 1px) calc(0.5rem - 1px) calc(1rem - 1px); + font-size: var(--font-size-xs); + line-height: 1rem; +} + +.views-field__skip-to-bulk-actions { + display: block; + white-space: nowrap; + font-size: var(--font-size-xs); +} + +@media screen and (min-width: 61rem) { + .views-bulk-actions { + flex-wrap: nowrap; + } + .views-bulk-actions__item { + margin-bottom: 0; + } + .views-bulk-actions__item--status { + width: auto; + } +} diff --git a/core/themes/claro/js/tableselect.js b/core/themes/claro/js/tableselect.js new file mode 100644 index 000000000000..feaffa10caf3 --- /dev/null +++ b/core/themes/claro/js/tableselect.js @@ -0,0 +1,374 @@ +/** + * @file + * Extends table select functionality for Claro. + */ + +(($, Drupal, { tabbable }) => { + Drupal.ClaroBulkActions = class { + constructor(bulkActions) { + this.bulkActions = bulkActions; + this.form = this.bulkActions.closest('form'); + this.form.querySelectorAll('tr').forEach((element) => { + element.classList.add('views-form__bulk-operations-row'); + }); + this.checkboxes = this.form.querySelectorAll( + '[class$="bulk-form"]:not(.select-all) input[type="checkbox"]', + ); + this.selectAll = this.form.querySelectorAll( + '.select-all > [type="checkbox"]', + ); + this.$tabbable = $(tabbable(this.form)); + this.bulkActionsSticky = false; + this.scrollingTimeout = ''; + this.ignoreScrollEvent = false; + + $(this.checkboxes).on('change', (event) => + this.rowCheckboxHandler(event), + ); + $(this.selectAll).on('change', (event) => this.selectAllHandler(event)); + this.$tabbable.on('focus', (event) => this.focusHandler(event)); + this.$tabbable.on('blur', (event) => this.blurHandler(event)); + + // The will contain the CSS that hides the spacer during scroll + // and resize. + this.spacerCss = document.createElement('style'); + document.body.appendChild(this.spacerCss); + + const scrollResizeHandler = Drupal.debounce(() => { + this.scrollResizeHandler(); + }, 10); + $(window).on('scroll', () => scrollResizeHandler()); + $(window).on('resize', () => scrollResizeHandler()); + + // Execute checkbox handler after the load event. This ensures that the + // actions form is sticky if any checkboxes are already checked on page + // load. One of the situations where it is possible to have pre-checked + // checkboxes on load is when the page is requested via the back button. + // window.addEventListener('load', () => this.rowCheckboxHandler({})); + $(window).on('load', () => this.rowCheckboxHandler({})); + } + + /** + * Ensures that focusable elements hidden under a sticky remain focusable. + * + * @param {Object} event + * A jQuery Event object. + */ + /* eslint-disable-next-line class-methods-use-this */ + blurHandler(event) { + // This event handler should only proceed if the event came from direct + // interaction with the form element. If this fires on events triggered + // via JavaScript there may be undesirable side effects. + if (!event.hasOwnProperty('isTrigger')) { + const row = event.target.closest('tr'); + const nextSibling = row ? row.nextElementSibling : null; + + // Any row in this table potentially has a spacer div preceding it. The + // spacer is added to prevent focusable elements from appearing + // underneath the sticky Views Bulk Actions form. Any element underneath + // this spacer is beneath the viewport. If an element beneath + // the viewport receives focus and the previously focused element was + // above the spacer, some browsers have difficulty determining how much + // scrolling is necessary to bring the newly focused element into view. + // To prevent this potential miscalculation, the spacer is momentarily + // removed when blur occurs on rows preceding it. The spacer is + // reintroduced immediately after the next item receives focus. + if ( + nextSibling && + nextSibling.getAttribute('data-drupal-table-row-spacer') + ) { + nextSibling.parentNode.removeChild(nextSibling); + } + } + } + + /** + * If a partially covered element receives focus, scroll it into full view. + * + * @param {Object} event + * A jQuery Event object. + */ + focusHandler(event) { + const stickyRect = this.bulkActions.getBoundingClientRect(); + const stickyStart = stickyRect.y; + const elementRect = event.target.getBoundingClientRect(); + const elementStart = elementRect.y; + const elementEnd = elementStart + elementRect.height; + if (elementEnd > stickyStart) { + window.scrollBy(0, elementEnd - stickyStart); + } + this.underStickyHandler(); + } + + /** + * Temporarily hides the spacer before calling underStickyHandler(). + * + * The spacer is added to prevent the "show numbers" functionality of speech + * navigation from labeling inputs under the stickied bulk actions form. It + * does this by pushing these elements further down the page so they are out + * of the viewport entirely. The presence of this spacer should be invisible + * to users. Because this invisibility is partially achieved via + * calculations based on scroll position and viewport size, the spacer is + * hidden during these events, and reintroduced 500 milliseconds after all + * scroll and resize events have completed. + */ + scrollResizeHandler() { + // Add CSS rule that hides the spacer. CSS is used instead of removing + // the spacer from the DOM as the change occurs faster. + this.spacerCss.innerHTML = + '[data-drupal-table-row-spacer] { display: none; }'; + + if (!this.ignoreScrollEvent) { + // Remove the timeout that unhides the spacer. If this function is called, + // then scrolling is still happening and spacers should stay hidden. + clearTimeout(this.scrollingTimeout); + + // Shortly after scrolling tops, the spacer is re-added. + this.scrollingTimeout = setTimeout(() => { + this.spacerCss.innerHTML = ''; + this.underStickyHandler(); + }, 500); + } + } + + /** + * Moves tabbable elements that are underneath the bulk actions form. + * + * Focusable elements inside a table row should not be positioned underneath + * a sticky Views Bulk Action form. If this isn't prevented, it can be + * confusing for speech navigation users when the "show numbers" feature + * is enabled. Numbers will be provided for the elements within the Bulk + * Actions form and the table row elements directly underneath, and it can + * be difficult to discern which number corresponds to which element. To + * prevent this confusion, a spacer div is added before the table row, and + * this spacer pushes the row further down so the focusable elements are out + * of viewport. + */ + underStickyHandler() { + document + .querySelectorAll('[data-drupal-table-row-spacer]') + .forEach((element) => { + element.parentNode.removeChild(element); + }); + + if (this.bulkActionsSticky) { + // Will be set to true as soon as the forEach() hits a row that is + // completely under the sticky header, indicating that no further + // processing is needed. Using a For...Of loop to accomplish this + // is preferable, but not supported by IE11. + let pastStickyHeader = false; + const stickyRect = this.bulkActions.getBoundingClientRect(); + const stickyStart = stickyRect.y; + const stickyEnd = stickyStart + stickyRect.height; + + // Loop through each table row. If a row has focusable elements under + // the sticky Views Bulk Actions form, add a spacer that pushes the row + // down the page and outside of the viewport. + this.form.querySelectorAll('tbody tr').forEach((row) => { + if (!pastStickyHeader) { + const rowRect = row.getBoundingClientRect(); + const rowStart = rowRect.y; + const rowEnd = rowStart + rowRect.height; + if (rowStart > stickyEnd) { + pastStickyHeader = true; + } else if (rowEnd > stickyStart) { + // Get padding amount for the row's cells, which are used to + // determine where a row can be pushed out of the viewport + // without any visible difference. + const cellTopPadding = Array.from( + row.querySelectorAll('td.views-field'), + ).map((element) => + document.defaultView + .getComputedStyle(element, '') + .getPropertyValue('padding-top') + .replace('px', ''), + ); + const minimumTopPadding = Math.min.apply(null, cellTopPadding); + + // If all parts of the table row that could be displaying content + // are under the sticky. + if (rowStart + minimumTopPadding >= stickyStart) { + // If the row scrolled underneath the sticky has the element + // with focus, the addition of a spacer can potentially create + // an additional scroll event that can lead to unwanted results. + // The variables below are used to identify this so a flag can + // be set to bypass scroll handler actions in just those + // instances. + const oldScrollTop = + window.pageYOffset || document.documentElement.scrollTop; + const scrollLeft = + window.pageXOffset || document.documentElement.scrollLeft; + const rowContainsActiveElement = row.contains( + document.activeElement, + ); + + // If the row contains the active element, set the flag that + // bypasses the actions of scrollResizeHandler() as a call to + // window.scrollTo() may be needed. + if (rowContainsActiveElement) { + this.ignoreScrollEvent = true; + } + + // a spacer to push it out of the viewport. Because the elements + // are fully underneath the sticky, the added spacer should not + // result in any visible difference. + const spacer = document.createElement('div'); + spacer.style.height = `${stickyRect.height}px`; + spacer.setAttribute('data-drupal-table-row-spacer', true); + row.parentNode.insertBefore(spacer, row); + + // Will be used to determine if a scroll position change + // occurred due to adding the spacer. + const newScrollTop = + window.pageYOffset || document.documentElement.scrollTop; + + // If the browser pushed the row back into the viewport after + // the spacer was added, return the scroll position to the + // intended location. + const windowBottom = + window.innerHeight || document.documentElement.clientHeight; + if ( + rowContainsActiveElement && + oldScrollTop !== newScrollTop && + rowStart < windowBottom + ) { + window.scrollTo(scrollLeft, oldScrollTop); + } + + // Set this flag back to its default value of false. + this.ignoreScrollEvent = false; + } + } + } + }); + } + } + + /** + * Triggered when the `select all` button is clicked. + * + * @param {Object} event + * A jQuery Event object. + */ + selectAllHandler(event) { + // This event handler should only proceed if the event came from direct + // interaction with the form element. If this fires on events triggered + // via JavaScript there may be undesirable side effects. + if (!event.hasOwnProperty('isTrigger')) { + const itemsCheckedCount = event.target.checked + ? this.checkboxes.length + : 0; + this.updateStatus(itemsCheckedCount); + this.underStickyHandler(); + } + } + + /** + * Triggered when a row is checked or unchecked. + * + * @param {Object} event + * A jQuery Event object. + */ + rowCheckboxHandler(event) { + // This event handler should only proceed if the event came from direct + // interaction with the form element. If this fires on events triggered + // via JavaScript there may be undesirable side effects. + if (!event.hasOwnProperty('isTrigger')) { + this.updateStatus( + Array.prototype.slice + .call(this.checkboxes) + .filter((checkbox) => checkbox.checked).length, + ); + } + } + + /** + * Update the bulk actions label and announcements. + * + * @param {number} count + * The number of checkboxes checked. + */ + updateStatus(count) { + // A status message that will be displayed in the bulk actions form and + // announced by the screen reader. + let statusMessage = ''; + + // This will remain empty unless the actions form is made sticky and + // previously was not. + let operationsAvailableMessage = ''; + if (count > 0) { + // Check if bulk operations has changed from not-sticky to sticky. + if (!this.bulkActionsSticky) { + operationsAvailableMessage = Drupal.t( + 'Bulk actions are now available. These actions will be applied to all selected items. This can be accessed via the "Skip to bulk actions" link that appears after every enabled checkbox. ', + ); + this.bulkActionsSticky = true; + + // Run the underStickyHandler after the CSS animation completes. + // Near the end of this there is an additional call to + // underStickyHandler without a timeout. This covers users who have + // animations disabled, and resets all items to visible if the bulk + // actions form is no longer sticky. + setTimeout(() => this.underStickyHandler(), 350); + + // When the actions form becomes sticky, it appears via an animation + // at the bottom of the viewport. If this form is already above the + // viewport, the animation would look odd. In these instances the + // animation is bypassed. + const stickyRect = this.bulkActions.getBoundingClientRect(); + + const bypassAnimation = + stickyRect.top + stickyRect.height < + window.scrollY + window.innerHeight; + + // Determine add/remove with ternary since IE11 does not support the + // second argument for classList.toggle(). + const classAction = bypassAnimation ? 'add' : 'remove'; + this.bulkActions.classList[classAction]( + 'views-form__header--bypass-animation', + ); + } + + statusMessage = Drupal.formatPlural( + count, + '1 item selected', + '@count items selected', + ); + } else { + this.bulkActionsSticky = false; + statusMessage = Drupal.t('No items selected'); + setTimeout(() => this.underStickyHandler(), 350); + } + + // Update the attribute that instructs the bulk actions form to be sticky. + this.bulkActions.setAttribute( + 'data-drupal-sticky-vbo', + this.bulkActionsSticky, + ); + + // Update the bulk actions form label with the number of items checked. + this.bulkActions.querySelector( + '[data-drupal-views-bulk-actions-status]', + ).textContent = statusMessage; + + // Announce these changes to the screen reader. + Drupal.announce(operationsAvailableMessage + statusMessage); + this.underStickyHandler(); + } + }; + + Drupal.behaviors.claroTableSelect = { + attach(context) { + const bulkActions = once( + 'ClaroBulkActions', + '[data-drupal-views-bulk-actions]', + context, + ); + bulkActions.map( + (bulkActionForm) => + /* eslint-disable-next-line no-new */ + new Drupal.ClaroBulkActions(bulkActionForm), + ); + }, + }; +})(jQuery, Drupal, window.tabbable, once); -- GitLab