diff --git a/css/builder.css b/css/builder.css index fd56d241a8902bec7b895d7fe64e803eef742cb4..a7b7cbb06a7afdda38207173f163644142e25bec 100644 --- a/css/builder.css +++ b/css/builder.css @@ -29,7 +29,8 @@ .lpb-empty-container__wrapper .lpb-section-menu__wrapper { bottom: 20px; } -.is-dragging .js-lpb-region { +.is-dragging .js-lpb-region, +.is-navigating .js-lpb-region { outline: 1px dotted blue; } .is-dragging .t-reversed .js-lpb-region { @@ -41,10 +42,13 @@ transition: all .15s linear; position: relative; } -.js-lpb-component:hover, -.js-lpb-component:focus-within { +.lp-builder:not(.is-navigating) .js-lpb-component:hover, +.lp-builder:not(.is-navigating) .js-lpb-component:focus-within { outline: 1px solid blue; } +.js-lpb-component.is-navigating { + outline: 3px solid blue; +} .js-lpb-component { cursor: grab; } @@ -52,13 +56,10 @@ .js-lpb-component:focus-within .js-lpb-region { outline: 1px dotted rgba(0, 0, 255, 0.5); } -.js-lpb-component:hover .js-lpb-region:hover, -.js-lpb-component:focus-within .js-lpb-region:focus-within { +.lp-builder:not(.is-navigating) .js-lpb-component:hover .js-lpb-region:hover, +.lp-builder:not(.is-navigating) .js-lpb-component:focus-within .js-lpb-region:focus-within { outline: 1px solid rgba(0, 0, 255, 0.5); } -.js-lpb-component:hover .js-lpb-active-item { - outline: 1px solid blue !important; -} .lpb-layout { padding: 20px; } @@ -69,10 +70,6 @@ padding: 5px 10px; font-size: small; } -.lpb-layout.lpb-active-item { - outline: 3px solid #fff !important; - box-shadow: 0 0 0 4px rgba(0, 0, 255, 1); -} .js-lpb-btn--add { position: absolute; } @@ -91,6 +88,10 @@ box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.25), 0px 1px 3px rgba(0, 0, 0, 0.25); background-color: #fff; } +.lpb-controls:hover, +.lpb-controls:focus-within { + z-index: 1010; +} .lpb-controls.is-layout { background-color: #00659B; border-radius: 6px 6px 0px 0px; @@ -100,8 +101,8 @@ left: default; right: -1px; } -.js-lpb-component:hover > .lpb-controls, -.js-lpb-component:focus-within > .lpb-controls { +.lp-builder:not(.is-navigating) .js-lpb-component:hover > .lpb-controls, +.lp-builder:not(.is-navigating) .js-lpb-component:focus-within > .lpb-controls { opacity: 1; } .lpb-controls a span { @@ -231,8 +232,8 @@ .js-lpb-component > .lpb-btn { opacity: 0; } -.js-lpb-component:hover > .lpb-btn, -.js-lpb-component:focus-within > .lpb-btn { +.lp-builder:not(.is-navigating) .js-lpb-component:hover > .lpb-btn, +.lp-builder:not(.is-navigating) .js-lpb-component:focus-within > .lpb-btn { opacity: 1; } .lpb-btn--add { @@ -257,6 +258,12 @@ background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M13 6V11H18V13H13V18H11V12.999L6 13V11L11 10.999V6H13Z' fill='%23130F13'/%3E%3C/svg%3E%0A"); opacity: 1; } +.lpb-btn--add:hover, +.lpb-btn:hover, +.lpb-btn--add:focus-within, +.lpb-btn:focus-within { + z-index: 1010; +} .js-lpb-component .lpb-btn--add, .js-lpb-region .lpb-btn--add { opacity: 0; @@ -275,13 +282,13 @@ .lpb-btn--add.after { bottom: -16px; } -.js-lpb-component:hover > .lpb-btn--add, -.js-lpb-component:focus-within > .lpb-btn--add { +.lp-builder:not(.is-navigating) .js-lpb-component:hover > .lpb-btn--add, +.lp-builder:not(.is-navigating) .js-lpb-component:focus-within > .lpb-btn--add { visibility: visible; opacity: 1; } -.js-lpb-region:hover > .lpb-btn--add, -.js-lpb-region:focus-within > .lpb-btn--add { +.lp-builder:not(.is-navigating) .js-lpb-region:hover > .lpb-btn--add, +.lp-builder:not(.is-navigating) .js-lpb-region:focus-within > .lpb-btn--add { visibility: visible; opacity: 1; } @@ -359,4 +366,43 @@ a.lpb-enable-button::before { .lpb-formatter:hover .lpb-enable, .lpb-formatter:focus-within .lpb-enable { opacity: 1; +} + +.lpb-tooltiptext { + opacity: 0; + transition: all .15s linear; + width: auto; + background-color: black; + color: #fff; + text-align: center; + padding: 10px; + position: absolute; + z-index: 1000; + bottom: 100%; + left: -7px; + margin-bottom: -2px; + white-space: nowrap; +} +.lpb-tooltiptext::after { + content: " "; + position: absolute; + top: 100%; /* At the bottom of the tooltip */ + left: 20px; + border-width: 7px; + border-style: solid; + border-color: black transparent transparent transparent; +} +.lp-builder:not(.is-dragging) .lpb-tooltip--hover:hover + .lpb-tooltiptext { + transition-delay: 1s; +} +.lp-builder:not(.is-dragging) .lpb-tooltip--focus:focus + .lpb-tooltiptext, +.lp-builder:not(.is-dragging) .lpb-tooltip--hover:hover + .lpb-tooltiptext, +.lpb-tooltiptext--visible{ + opacity: 1; +} +.js-lpb-ui-message { + background-color: 000; + color: #fff; + text-align: center; + position: static; } \ No newline at end of file diff --git a/js/builder.js b/js/builder.js index c34528cc0bfbc54e95f61e6bea3a6637d5dd01eb..6674903ff37a0863dd0e3fc7cc431260eebc1946 100644 --- a/js/builder.js +++ b/js/builder.js @@ -7,13 +7,15 @@ * The container. * @param {string} id * The container id. + * @param {Object} settings + * The settings object. */ function attachUiElements($container, id, settings) { const lpbBuilderSettings = settings.lpBuilder || {}; const uiElements = lpbBuilderSettings.uiElements || {}; const containerUiElements = uiElements[id] || []; - containerUiElements.forEach((uiElement) => { - const {element, method} = uiElement; + containerUiElements.forEach(uiElement => { + const { element, method } = uiElement; $container[method](element); }); Drupal.behaviors.AJAX.attach($container[0], drupalSettings); @@ -138,7 +140,9 @@ $moveItem.css({ transform: 'none' }); $sibling.css({ transform: 'none' }); $sibling[method]($moveItem); - $moveItem.closest(`[${idAttr}]`).trigger('lpb-component:move', [$moveItem.attr('data-uuid')]); + $moveItem + .closest(`[${idAttr}]`) + .trigger('lpb-component:move', [$moveItem.attr('data-uuid')]); }, }, ); @@ -160,7 +164,7 @@ // Add shims as target elements. if (dir === -1) { $( - '.js-lpb-region .lpb-btn--add, .lpb-layout:not(.lpb-active-item)', + '.js-lpb-region .lpb-btn--add.center, .lpb-layout:not(.lpb-active-item)', $element, ).before('<div class="lpb-shim"></div>'); } else if (dir === 1) { @@ -198,7 +202,34 @@ // Remove the shims and save the order. $('.lpb-shim', $element).remove(); $item.removeClass('lpb-active-item').focus(); - $item.closest(`[${idAttr}]`).trigger('lpb-component:move', [$item.attr('data-uuid')]); + $item + .closest(`[${idAttr}]`) + .trigger('lpb-component:move', [$item.attr('data-uuid')]); + } + function startNav($item) { + const $msg = $( + `<div id="lpb-navigating-msg" class="lpb-tooltiptext lpb-tooltiptext--visible js-lpb-tooltiptext">${Drupal.t( + 'Use arrow keys to move. Press Return or Tab when finished.', + )}</div>`, + ); + $item + .closest('.lp-builder') + .addClass('is-navigating') + .find('.is-navigating') + .removeClass('is-navigating'); + $item + .attr('aria-describedby', 'lpb-navigating-msg') + .addClass('is-navigating') + .prepend($msg); + } + function stopNav($item) { + $item + .removeClass('is-navigating') + .closest('.lp-builder') + .removeClass('is-navigating') + .attr('aria-describedby', '') + .find('.js-lpb-tooltiptext') + .remove(); } /** * Prevents user from navigating away and accidentally loosing changes. @@ -246,14 +277,28 @@ $(e.currentTarget).focus(); return false; }); + $element.on('click.lp-builder', '.lpb-drag', e => { + const $btn = $(e.currentTarget); + startNav($btn.closest('.js-lpb-component')); + }); document.addEventListener('keydown', e => { - const $item = $('.js-lpb-component:focus'); + const $item = $('.js-lpb-component.is-navigating'); if ($item.length) { - if (e.code === 'ArrowDown' && $item) { - nav($item, 1, settings); - } - if (e.code === 'ArrowUp' && $item) { - nav($item, -1, settings); + switch (e.code) { + case 'ArrowUp': + case 'ArrowLeft': + nav($item, -1, settings); + break; + case 'ArrowDown': + case 'ArrowRight': + nav($item, 1, settings); + break; + case 'Enter': + case 'Tab': + stopNav($item); + break; + default: + break; } } }); @@ -345,11 +390,15 @@ } } }); - Drupal.AjaxCommands.prototype.LayoutParagraphsEventCommand = (ajax, response, status) => { - const {layoutId, componentUuid, eventName} = response; + Drupal.AjaxCommands.prototype.LayoutParagraphsEventCommand = ( + ajax, + response, + status, + ) => { + const { layoutId, componentUuid, eventName } = response; const $element = $(`[data-lpb-id="${layoutId}"]`); $element.trigger(`lpb-${eventName}`, [componentUuid]); - } + }; Drupal.behaviors.layoutParagraphsBuilder = { attach: function attach(context, settings) { // Initialize the editor ui. @@ -380,22 +429,23 @@ 'lpb-component:update.lpb', 'lpb-component:move.lpb', 'lpb-component:drop.lpb', - 'lpb-component:delete.lpb' + 'lpb-component:delete.lpb', ].join(' '); - $('[data-lpb-id]').once('lpb-events').on(events, e => { - const $element = $(e.currentTarget); - updateUi($element); - }); + $('[data-lpb-id]') + .once('lpb-events') + .on(events, e => { + const $element = $(e.currentTarget); + updateUi($element); + }); // Add UI elements to the builder, each component, and each region. - [ - `${idAttr}`, - 'data-uuid', - 'data-region-uuid', - ].forEach((attr) => { - $(`[${attr}]`).not('.has-components').once('lpb-ui-elements').each((i, el) => { - attachUiElements($(el), el.getAttribute(attr), settings); - }); + [`${idAttr}`, 'data-uuid', 'data-region-uuid'].forEach(attr => { + $(`[${attr}]`) + .not('.has-components') + .once('lpb-ui-elements') + .each((i, el) => { + attachUiElements($(el), el.getAttribute(attr), settings); + }); }); }, }; diff --git a/layout_paragraphs.module b/layout_paragraphs.module index b9d83d007ab77bd605faa063c30cc6bf1a89e67c..2dc2109970501310658623a3f94695f0c6bc456f 100644 --- a/layout_paragraphs.module +++ b/layout_paragraphs.module @@ -61,6 +61,7 @@ function layout_paragraphs_theme() { 'label' => NULL, 'edit_attributes' => [], 'delete_attributes' => [], + 'unique_id' => [], ], ], 'layout_paragraphs_builder_component_menu' => [ diff --git a/src/Element/LayoutParagraphsBuilder.php b/src/Element/LayoutParagraphsBuilder.php index abe82fc98bf6eaf709bdc0c934a5e7fec2224a3c..7f3b25a42e1b8d2d17a4ad012e7e9015c09c4c52 100644 --- a/src/Element/LayoutParagraphsBuilder.php +++ b/src/Element/LayoutParagraphsBuilder.php @@ -2,25 +2,26 @@ namespace Drupal\layout_paragraphs\Element; -use Drupal\Component\Serialization\Json; use Drupal\Core\Url; use Drupal\Core\Render\Markup; use Drupal\Core\Render\Renderer; +use Drupal\Component\Utility\Html; +use Drupal\Core\Template\Attribute; +use Drupal\Component\Serialization\Json; +use Drupal\paragraphs\ParagraphInterface; use Drupal\Core\Access\AccessResultAllowed; use Drupal\Core\Layout\LayoutPluginManager; use Drupal\Core\Entity\EntityTypeBundleInfo; use Drupal\Core\Access\AccessResultForbidden; -use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Render\Element\RenderElement; +use Drupal\layout_paragraphs\DialogHelperTrait; +use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\Plugin\ContainerFactoryPluginInterface; -use Drupal\Core\Template\Attribute; -use Drupal\paragraphs\ParagraphInterface; use Drupal\layout_paragraphs\LayoutParagraphsSection; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\layout_paragraphs\LayoutParagraphsComponent; -use Drupal\layout_paragraphs\LayoutParagraphsLayoutTempstoreRepository; -use Drupal\layout_paragraphs\DialogHelperTrait; use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\layout_paragraphs\LayoutParagraphsLayoutTempstoreRepository; /** * Defines a render element for building the Layout Builder UI. @@ -332,7 +333,7 @@ class LayoutParagraphsBuilder extends RenderElement implements ContainerFactoryP else { $delete_attributes = []; } - + $controls = [ '#theme' => 'layout_paragraphs_builder_controls', '#attributes' => [ @@ -343,6 +344,7 @@ class LayoutParagraphsBuilder extends RenderElement implements ContainerFactoryP '#label' => $entity->getParagraphType()->label, '#edit_attributes' => $edit_attributes, '#delete_attributes' => $delete_attributes, + '#unique_id' => Html::getUniqueId('lpb-controls'), '#weight' => -10001, ]; if ($component->isLayout()) { diff --git a/templates/layout-paragraphs-builder-controls.html.twig b/templates/layout-paragraphs-builder-controls.html.twig index 5f957b0542a26abcde022b67db386b284162fcb3..96b0919ea9cd8a9991a91a578bdf4340d7ec2098 100644 --- a/templates/layout-paragraphs-builder-controls.html.twig +++ b/templates/layout-paragraphs-builder-controls.html.twig @@ -1,5 +1,6 @@ <div{{ attributes }}> - <a class="lpb-drag" href="#nav"><span>{{ 'Nav'|t }}</span></a> + <a class="lpb-drag lpb-tooltip--hover lpb-tooltip--focus" aria-describedby="{{unique_id}}--tip" href="#nav"><span>{{ 'Nav'|t }}</span></a> + <span class="lpb-tooltiptext" id="{{unique_id}}--tip">{{ 'Drag or click and use arrow keys to move. <br />Press Return or Tab when finished.'|t }}</span> <span class="lpb-controls-label">{{label}}</span> <a class="lpb-up" href="#move-up"><span>{{ 'Move Up'|t }}</span></a> <a class="lpb-down" href="#move-down"><span>{{ 'Move Down'|t }}</span></a> diff --git a/tests/src/FunctionalJavascript/BuilderTest.php b/tests/src/FunctionalJavascript/BuilderTest.php index 9c18187ffe6d8cbb570a1b7cf59edf8f3a2ed04f..13433ee42bd471021b4a4e619ce10f250180e67d 100644 --- a/tests/src/FunctionalJavascript/BuilderTest.php +++ b/tests/src/FunctionalJavascript/BuilderTest.php @@ -117,36 +117,26 @@ class BuilderTest extends WebDriverTestBase { $third_col = $page->find('css', '.layout__region--third'); $this->assertNotEmpty($third_col); - // Add a text item to first column. - // Because there are only two component types and sections cannot - // be nested, this will load the text component form directly. - $button = $page->find('css', '.layout__region--first .lpb-btn--add'); - $button->click(); - $this->assertSession()->assertWaitOnAjaxRequest(); - $this->assertSession()->pageTextContains('field_text'); - - $page->fillField('field_text[0][value]', 'Some arbitrary text'); - // Force show the hidden submit button so we can click it. - $this->getSession()->executeScript("jQuery('.lpb-btn--save').attr('style', '');"); - $button = $this->assertSession()->waitForElementVisible('css', ".lpb-btn--save"); - $button->press(); - - $this->assertSession()->assertWaitOnAjaxRequest(); - $this->assertSession()->pageTextContains('Some arbitrary text'); + } + /** + * Tests adding a component into a section. + */ + public function testAddComponent() { + $this->testAddSection(); + $this->addTextComponent('Some arbitrary text', '.layout__region--first .lpb-btn--add'); $this->submitForm([ 'title[0][value]' => 'Node title', ], 'Save'); $this->assertSession()->pageTextContains('Node title'); $this->assertSession()->pageTextContains('Some arbitrary text'); - } /** * Tests editing a paragraph. */ public function testEditComponent() { - $this->testAddSection(); + $this->testAddComponent(); $this->drupalGet('node/1/edit'); $page = $this->getSession()->getPage(); @@ -161,35 +151,14 @@ class BuilderTest extends WebDriverTestBase { * Tests reordering components. */ public function testReorderComponents() { - $this->testAddSection(); + $this->testAddComponent(); $this->drupalGet('node/1/edit'); $page = $this->getSession()->getPage(); + $this->addTextComponent('Second text item.', '[data-id="2"] .lpb-btn--add.after'); + $this->assertOrderOfStrings(['Some arbitrary text', 'Second text item.'], 'Second item was not correctly added after the first.'); - // Add a SECOND text item to first column. - // Because there are only two component types and sections cannot - // be nested, this will load the text component form directly. - $button = $page->find('css', '[data-id="2"] .lpb-btn--add.after'); - $button->click(); - $this->assertSession()->assertWaitOnAjaxRequest(); - $this->assertSession()->pageTextContains('field_text'); - - $page->fillField('field_text[0][value]', 'Second text item.'); - // Force show the hidden submit button so we can click it. - $this->getSession()->executeScript("jQuery('.lpb-btn--save').attr('style', '');"); - $button = $this->assertSession()->waitForElementVisible('css', ".lpb-btn--save"); - $button->press(); - $this->assertSession()->assertWaitOnAjaxRequest(1000, 'Could not save new component.'); - - // Make sure the new component was added AFTER the existing one. - $page_text = $page->getHtml(); - $pos1 = strpos($page_text, 'Second text item.'); - $pos2 = strpos($page_text, 'Some arbitrary text'); - if ($pos1 < $pos2) { - throw new ExpectationException("New component was incorrectly added above the existing one.", $this->getSession()->getDriver()); - } - - // Move the new item up above the first. + // Click the new item's move up button. $button = $page->find('css', '.is_new .lpb-up'); $button->click(); @@ -200,19 +169,94 @@ class BuilderTest extends WebDriverTestBase { $this->assertSession()->pageTextContains('Second text item.'); // The second component should now appear first in the page source. - $page_text = $page->getHtml(); - $pos1 = strpos($page_text, 'Second text item.'); - $pos2 = strpos($page_text, 'Some arbitrary text'); - if ($pos1 > $pos2) { - throw new ExpectationException("Components were not correctly reordered.", $this->getSession()->getDriver()); - } + $this->assertOrderOfStrings(['Second text item.', 'Some arbitrary text'], 'Components were not correctly reordered.'); + } + + /** + * Tests keyboard navigation. + */ + public function testKeyboardNavigation() { + + $this->testAddSection(); + $page = $this->getSession()->getPage(); + $this->submitForm([ + 'title[0][value]' => 'Node title', + ], 'Save'); + + $this->drupalGet('node/1/edit'); + $this->addTextComponent('First item', '.layout__region--first .lpb-btn--add'); + $this->addTextComponent('Second item', '.layout__region--second .lpb-btn--add'); + $this->addTextComponent('Third item', '.layout__region--third .lpb-btn--add'); + + // Click the new item's drag button. + // This should create a <div> with the id 'lpb-navigatin-msg'. + $button = $page->find('css', '.layout__region--third .lpb-drag'); + $button->click(); + $this->assertSession()->elementExists('css', '#lpb-navigating-msg'); + + // Moves third item to bottom of second region. + $this->keyPress('ArrowUp'); + $this->assertOrderOfStrings(['First item', 'Second item', 'Third item']); + + // Moves third item to top of second region. + $this->keyPress('ArrowUp'); + $this->assertOrderOfStrings(['First item', 'Third item', 'Second item']); + + // Moves third item to bottom of first region. + $this->keyPress('ArrowUp'); + $this->assertOrderOfStrings(['First item', 'Third item', 'Second item']); + // Moves third item to top of first region. + $this->keyPress('ArrowUp'); + $this->assertOrderOfStrings(['Third item', 'First item', 'Second item']); + + // Save the node. + $this->submitForm([ + 'title[0][value]' => 'Node title', + ], 'Save'); + + // Ensures reordering was correctly applied via Ajax. + $this->assertOrderOfStrings(['Third item', 'First item', 'Second item']); } + /** + * Uses Javascript to make a DOM element visible. + * + * @param string $selector + * A css selector. + */ protected function forceVisible($selector) { $this->getSession()->executeScript("jQuery('{$selector} .contextual .trigger').toggleClass('visually-hidden');"); } + /** + * Inserts a text component by clicking the "+" button. + * + * @param string $text + * The text for the component's field_text value. + * @param string $css_selector + * A css selector targeting the "+" button. + */ + protected function addTextComponent($text, $css_selector) { + $page = $this->getSession()->getPage(); + // Add a text item to first column. + // Because there are only two component types and sections cannot + // be nested, this will load the text component form directly. + $button = $page->find('css', $css_selector); + $button->click(); + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertSession()->pageTextContains('field_text'); + + $page->fillField('field_text[0][value]', $text); + // Force show the hidden submit button so we can click it. + $this->getSession()->executeScript("jQuery('.lpb-btn--save').attr('style', '');"); + $button = $this->assertSession()->waitForElementVisible('css', ".lpb-btn--save"); + $button->press(); + + $this->assertSession()->assertWaitOnAjaxRequest(); + $this->assertSession()->pageTextContains($text); + } + /** * Creates a new user with provided permissions and logs them in. * @@ -228,4 +272,51 @@ class BuilderTest extends WebDriverTestBase { return $user; } + /** + * {@inheritDoc} + * + * Added method with fixed return comment for IDE type hinting. + * + * @return \Drupal\FunctionalJavascriptTests\JSWebAssert + * A new JS web assert object. + */ + public function assertSession($name = '') { + $js_web_assert = parent::assertSession($name); + return $js_web_assert; + } + + /** + * Asserts that provided strings appear on page in same order as in array. + * + * @param array $strings + * A list of strings in the order they are expected to appear. + * @param string $assert_message + * Message if assertion fails. + */ + protected function assertOrderOfStrings(array $strings, $assert_message = 'Strings are not in correct order.') { + $page = $this->getSession()->getPage(); + $page_text = $page->getHtml(); + $highmark = -1; + foreach ($strings as $string) { + $this->assertSession()->pageTextContains($string); + $pos = strpos($page_text, $string); + if ($pos <= $highmark) { + throw new ExpectationException($assert_message, $this->getSession()->getDriver()); + } + $highmark = $pos; + } + } + + /** + * Simulates pressing a key with javascript. + * + * @param string $key_code + * The string key code (i.e. ArrowUp, Enter). + */ + protected function keyPress($key_code) { + $script = 'var e = new KeyboardEvent("keydown", {bubbles : true, cancelable : true, code: "' . $key_code . '"}); + document.body.dispatchEvent(e);'; + $this->getSession()->executeScript($script); + } + }