diff --git a/core/modules/layout_builder/css/layout-builder.css b/core/modules/layout_builder/css/layout-builder.css index 71e1979a54..f9d349889f 100644 --- a/core/modules/layout_builder/css/layout-builder.css +++ b/core/modules/layout_builder/css/layout-builder.css @@ -51,10 +51,6 @@ text-align: center; } -.layout-section .layout-builder--layout__region .block { - padding: 1.5em; -} - .layout-section .remove-section { position: relative; background: url(../../../misc/icons/bebebe/ex.svg) #fff center center / 16px 16px no-repeat; @@ -75,6 +71,14 @@ background-image: url(../../../misc/icons/787878/ex.svg); } +.layout-builder-block { + padding: 1.5em; +} + +.layout-builder-block [tabindex="-1"] { + pointer-events: none; +} + #drupal-off-canvas .layout-selection li { display: block; padding-bottom: 1em; diff --git a/core/modules/layout_builder/js/layout-builder.es6.js b/core/modules/layout_builder/js/layout-builder.es6.js index 543d0f6c54..f882224a4d 100644 --- a/core/modules/layout_builder/js/layout-builder.es6.js +++ b/core/modules/layout_builder/js/layout-builder.es6.js @@ -160,4 +160,39 @@ }); }, }; + + /** + * Disables interactive elements in previewed blocks. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attach disabling interactive elements behavior to the Layout Builder UI. + */ + behaviors.layoutBuilderDisableInteractiveElements = { + attach() { + // Disable interactive elements inside preview blocks. + const $blocks = $('#layout-builder [data-layout-block-uuid]'); + $blocks.find('input, textarea, select').prop('disabled', true); + $blocks.find('a').on('click mouseup touchstart', e => { + e.preventDefault(); + e.stopPropagation(); + }); + + /* + * In preview blocks, remove from the tabbing order all input elements + * and elements specifically assigned a tab index, other than those + * related to contextual links. + */ + $blocks + .find( + 'button, [href], input, select, textarea, iframe, [tabindex]:not([tabindex="-1"]):not(.tabbable)', + ) + .not( + (index, element) => + $(element).closest('[data-contextual-id]').length > 0, + ) + .attr('tabindex', -1); + }, + }; })(jQuery, Drupal); diff --git a/core/modules/layout_builder/js/layout-builder.js b/core/modules/layout_builder/js/layout-builder.js index 8c7bb789cc..2495e7e935 100644 --- a/core/modules/layout_builder/js/layout-builder.js +++ b/core/modules/layout_builder/js/layout-builder.js @@ -76,4 +76,19 @@ }); } }; + + behaviors.layoutBuilderDisableInteractiveElements = { + attach: function attach() { + var $blocks = $('#layout-builder [data-layout-block-uuid]'); + $blocks.find('input, textarea, select').prop('disabled', true); + $blocks.find('a').on('click mouseup touchstart', function (e) { + e.preventDefault(); + e.stopPropagation(); + }); + + $blocks.find('button, [href], input, select, textarea, iframe, [tabindex]:not([tabindex="-1"]):not(.tabbable)').not(function (index, element) { + return $(element).closest('[data-contextual-id]').length > 0; + }).attr('tabindex', -1); + } + }; })(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/layout_builder/layout_builder.module b/core/modules/layout_builder/layout_builder.module index 6aeb55d4c3..ba38bf7377 100644 --- a/core/modules/layout_builder/layout_builder.module +++ b/core/modules/layout_builder/layout_builder.module @@ -40,6 +40,7 @@ function layout_builder_help($route_name, RouteMatchInterface $route_match) { else { $output .= '

' . t('To manage other areas of the page, use the block administration page.') . '

'; } + $output .= '

' . t('Forms and links inside the content of the layout builder tool have been disabled.') . '

'; return $output; } diff --git a/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php b/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php index 8b445bd773..795a300b08 100644 --- a/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php +++ b/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php @@ -106,6 +106,7 @@ public function onBuildRender(SectionComponentBuildRenderArrayEvent $event) { '#base_plugin_id' => $block->getBaseId(), '#derivative_plugin_id' => $block->getDerivativeId(), '#weight' => $event->getComponent()->getWeight(), + '#attributes' => ['class' => ['layout-builder-block']], 'content' => $content, ]; if ($is_content_empty && $is_placeholder_ready) { diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/css/layout_builder_test_css_transitions.test.css b/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/css/layout_builder_test_css_transitions.test.css new file mode 100644 index 0000000000..e36e723527 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/css/layout_builder_test_css_transitions.test.css @@ -0,0 +1,7 @@ +/** + * Remove all transitions for testing. + */ +* { + /* CSS transitions. */ + transition: none !important; +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/layout_builder_test_css_transitions.info.yml b/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/layout_builder_test_css_transitions.info.yml new file mode 100644 index 0000000000..9bce43b41d --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/layout_builder_test_css_transitions.info.yml @@ -0,0 +1,7 @@ +# @todo Remove this module & its usages in https://www.drupal.org/node/2901792. +name: 'Layout Builder Test Disable Animations' +type: module +description: 'Disables CSS animations for tests ' +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/layout_builder_test_css_transitions.libraries.yml b/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/layout_builder_test_css_transitions.libraries.yml new file mode 100644 index 0000000000..f010cdfb73 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/layout_builder_test_css_transitions.libraries.yml @@ -0,0 +1,4 @@ +layout_builder.disable_css_transitions: + css: + component: + css/layout_builder_test_css_transitions.test.css: {} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/layout_builder_test_css_transitions.module b/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/layout_builder_test_css_transitions.module new file mode 100644 index 0000000000..293910f6d0 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test_css_transitions/layout_builder_test_css_transitions.module @@ -0,0 +1,16 @@ +drupalPlaceBlock('local_tasks_block'); + + $this->createContentType(['type' => 'bundle_with_section_field']); + $this->createNode([ + 'type' => 'bundle_with_section_field', + 'title' => 'The first node title', + 'body' => [ + [ + 'value' => 'Node body', + ], + ], + ]); + + $bundle = BlockContentType::create([ + 'id' => 'basic', + 'label' => 'Basic block', + 'revision' => 1, + ]); + $bundle->save(); + block_content_add_body_field($bundle->id()); + + BlockContent::create([ + 'type' => 'basic', + 'info' => 'Block with link', + 'body' => [ + // Create a link that should be disabled in Layout Builder preview. + 'value' => 'Take me away', + 'format' => 'full_html', + ], + ])->save(); + + BlockContent::create([ + 'type' => 'basic', + 'info' => 'Block with iframe', + 'body' => [ + // Add iframe that should be non-interactive in Layout Builder preview. + 'value' => '', + 'format' => 'full_html', + ], + ])->save(); + } + + /** + * Tests that forms and links are disabled in the Layout Builder preview. + */ + public function testFormsLinksDisabled() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalLogin($this->drupalCreateUser([ + 'configure any layout', + 'administer node display', + 'administer node fields', + 'search content', + 'access contextual links', + ])); + + $field_ui_prefix = 'admin/structure/types/manage/bundle_with_section_field'; + + $this->drupalPostForm("$field_ui_prefix/display", ['layout[enabled]' => TRUE], 'Save'); + $assert_session->linkExists('Manage layout'); + $this->clickLink('Manage layout'); + + // Add a block with a form, another with a link, and one with an iframe. + $this->addBlock('Search form', '#layout-builder .search-block-form'); + $this->addBlock('Block with link', '#link-that-should-be-disabled'); + $this->addBlock('Block with iframe', '#iframe-that-should-be-disabled'); + + // Ensure the links and forms are disabled using the defaults before the + // layout is saved. + $this->assertLinksFormIframeNotInteractive(); + + $page->pressButton('Save layout'); + $this->clickLink('Manage layout'); + + // Ensure the links and forms are disabled using the defaults. + $this->assertLinksFormIframeNotInteractive(); + + // Ensure contextual links were not disabled. + $this->assertContextualLinksClickable(); + + $this->drupalPostForm("$field_ui_prefix/display/default", ['layout[allow_custom]' => TRUE], 'Save'); + $this->drupalGet('node/1/layout'); + + // Ensure the links and forms are also disabled in using the override. + $this->assertLinksFormIframeNotInteractive(); + + // Ensure contextual links were not disabled. + $this->assertContextualLinksClickable(); + } + + /** + * Adds a block in the Layout Builder. + * + * @param string $block_link_text + * The link text to add the block. + * @param string $rendered_locator + * The CSS locator to confirm the block was rendered. + */ + protected function addBlock($block_link_text, $rendered_locator) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + // Add a new block. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#layout-builder a:contains(\'Add Block\')')); + $this->clickLink('Add Block'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas')); + $assert_session->assertWaitOnAjaxRequest(); + + $assert_session->linkExists($block_link_text); + $this->clickLink($block_link_text); + + // Wait for off-canvas dialog to reopen with block form. + $this->assertNotEmpty($assert_session->waitForElementVisible('css', ".layout-builder-add-block")); + $assert_session->assertWaitOnAjaxRequest(); + $page->pressButton('Add Block'); + + // Wait for block form to be rendered in the Layout Builder. + $this->assertNotEmpty($assert_session->waitForElement('css', $rendered_locator)); + } + + /** + * Checks if element is unclickable. + * + * @param \Behat\Mink\Element\NodeElement $element + * Element being checked for. + */ + protected function assertElementUnclickable(NodeElement $element) { + try { + $element->click(); + $tag_name = $element->getTagName(); + $this->fail(new FormattableMarkup("@tag_name was clickable when it shouldn't have been", ['@tag_name' => $tag_name])); + } + catch (UnknownError $e) { + $this->assertContains('is not clickable at point', $e->getMessage()); + } + } + + /** + * Asserts that forms, links, and iframes in preview are non-interactive. + */ + protected function assertLinksFormIframeNotInteractive() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->assertNotEmpty($assert_session->waitForElement('css', '.block-search')); + $searchButton = $assert_session->buttonExists('Search'); + $this->assertElementUnclickable($searchButton); + $assert_session->linkExists('Take me away'); + $this->assertElementUnclickable($page->findLink('Take me away')); + $iframe = $assert_session->elementExists('css', '#iframe-that-should-be-disabled'); + $this->assertElementUnclickable($iframe); + } + + /** + * Confirms that Layout Builder contextual links remain active. + */ + protected function assertContextualLinksClickable() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->drupalGet($this->getUrl()); + + $this->clickContextualLink('.block-field-blocknodebundle-with-section-fieldbody [data-contextual-id^="layout_builder_block"]', 'Configure'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.ui-dialog-titlebar [title="Close"]')); + $page->pressButton('Close'); + $this->assertNoElementAfterWait('#drupal-off-canvas'); + + // Run the steps a second time after closing dialog, which reverses the + // order that behaviors.layoutBuilderDisableInteractiveElements and + // contextual link initialization occurs. + $this->clickContextualLink('.block-field-blocknodebundle-with-section-fieldbody [data-contextual-id^="layout_builder_block"]', 'Configure'); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '#drupal-off-canvas')); + } + + /** + * Waits for an element to be removed from the page. + * + * @param string $selector + * CSS selector. + * @param int $timeout + * (optional) Timeout in milliseconds, defaults to 10000. + * @param string $message + * (optional) Custom message to display with the assertion. + * + * @todo: Remove after https://www.drupal.org/project/drupal/issues/2892440 + */ + public function assertNoElementAfterWait($selector, $timeout = 10000, $message = '') { + $page = $this->getSession()->getPage(); + if ($message === '') { + $message = "Element '$selector' was not on the page after wait."; + } + $this->assertTrue($page->waitFor($timeout / 1000, function () use ($page, $selector) { + return empty($page->find('css', $selector)); + }), $message); + } + +} diff --git a/core/modules/layout_builder/tests/src/Unit/BlockComponentRenderArrayTest.php b/core/modules/layout_builder/tests/src/Unit/BlockComponentRenderArrayTest.php index 2ac7994d74..f0d83e80fb 100644 --- a/core/modules/layout_builder/tests/src/Unit/BlockComponentRenderArrayTest.php +++ b/core/modules/layout_builder/tests/src/Unit/BlockComponentRenderArrayTest.php @@ -112,6 +112,7 @@ public function testOnBuildRender($refinable_dependent_access) { '#base_plugin_id' => 'block_plugin_id', '#derivative_plugin_id' => NULL, 'content' => $block_content, + '#attributes' => ['class' => ['layout-builder-block']], ]; $expected_cache = $expected_build + [ @@ -236,6 +237,7 @@ public function testOnBuildRenderInPreview($refinable_dependent_access) { '#base_plugin_id' => 'block_plugin_id', '#derivative_plugin_id' => NULL, 'content' => $block_content, + '#attributes' => ['class' => ['layout-builder-block']], ]; $expected_cache = $expected_build + [ @@ -287,6 +289,7 @@ public function testOnBuildRenderInPreviewEmptyBuild() { '#base_plugin_id' => 'block_plugin_id', '#derivative_plugin_id' => NULL, 'content' => $block_content, + '#attributes' => ['class' => ['layout-builder-block']], ]; $expected_build['content']['#markup'] = $placeholder_string; diff --git a/core/modules/layout_builder/tests/src/Unit/SectionRenderTest.php b/core/modules/layout_builder/tests/src/Unit/SectionRenderTest.php index ad94e14e18..158b350ac1 100644 --- a/core/modules/layout_builder/tests/src/Unit/SectionRenderTest.php +++ b/core/modules/layout_builder/tests/src/Unit/SectionRenderTest.php @@ -108,6 +108,7 @@ public function testToRenderArray() { '#base_plugin_id' => 'block_plugin_id', '#derivative_plugin_id' => NULL, 'content' => $block_content, + '#attributes' => ['class' => ['layout-builder-block']], '#cache' => [ 'contexts' => [], 'tags' => [], @@ -186,6 +187,7 @@ public function testToRenderArrayPreview() { '#base_plugin_id' => 'block_plugin_id', '#derivative_plugin_id' => NULL, 'content' => $block_content, + '#attributes' => ['class' => ['layout-builder-block']], '#cache' => [ 'contexts' => [], 'tags' => [], @@ -240,6 +242,7 @@ public function testContextAwareBlock() { '#base_plugin_id' => 'block_plugin_id', '#derivative_plugin_id' => NULL, 'content' => $block_content, + '#attributes' => ['class' => ['layout-builder-block']], '#cache' => [ 'contexts' => [], 'tags' => [],