Loading admin_toolbar_search/css/admin_toolbar_search.css +6 −4 Original line number Diff line number Diff line Loading @@ -47,6 +47,12 @@ max-height: 18.75rem; font-size: 0.8125rem; &.ui-autocomplete { /* override core 'toolbar.module.css' class '.toolbar .toolbar-tray' default value of 'z-index:501', to prevent overlaps with other toolbar trays. */ z-index: 600; } /* Styles for each suggestion item in the autocomplete list. */ .ui-menu-item { /* Add a bit of vertical spacing between the list items. */ Loading @@ -68,10 +74,6 @@ color: white; } } /* Hide link URLs in the span tags within autocomplete suggestion items. */ .admin-toolbar-search-url { display: none; } } } Loading admin_toolbar_search/js/admin_toolbar_search.js +247 −112 Original line number Diff line number Diff line /** * @file * Behaviors for the search widget in the admin toolbar. * Behaviors for the autocomplete search widget in the drupal core toolbar. * * @param {Function} once * The once library used to ensure behaviors are attached only once. * @param {Drupal} Drupal * The Drupal global used to define behaviors. * @param {jQuery} $ * The jQuery library used to instantiate the autocomplete widget. */ (function ($, Drupal) { 'use strict'; ((once, Drupal, $) => { /** * Integrates jQuery UI Autocomplete with Admin Toolbar for searching links. * * @namespace Drupal.behaviors.adminToolbarSearch * * @type {Object} * More specifically, a Drupal behavior. * * @prop {Function} attach * Attaches the behavior to instantiate the jQuery autocomplete search field * in the admin toolbar. * * @see https://api.jqueryui.com/autocomplete/ */ Drupal.behaviors.adminToolbarSearch = { // If extra links have been fetched. extraFetched: false, attach: function (context) { if (context != document) { attach(context) { // Attach only when the whole document is loaded. if (context !== document) { return; } // Search input field ID in toolbar tray and mobile support version. const searchInputID = '#admin-toolbar-search-input'; // Search input field ID in toolbar bar. const searchFieldInputID = '#admin-toolbar-search-field-input'; var $self = this; // Ensure the autocomplete is only called once when the toolbar is loaded. once('admin-toolbar-search', '#toolbar-bar', context).forEach( (toolbarBar) => { // Get the two search input fields on which to call the autocomplete. const searchInputFields = toolbarBar.querySelectorAll( [searchInputID, searchFieldInputID].join(', '), ); const elements = once('admin-toolbar-search', '#toolbar-bar', context); $(elements).each(function () { $self.links = []; // Skip any further processing if no search input field is found. if (searchInputFields.length === 0) { return; } var $searchInputs = $(this).find('#admin-toolbar-search-input, #admin-toolbar-search-field-input'); /** * The list of menu links collected from the admin toolbar trays for * autocomplete searching. * * @param {Array} */ const menuLinks = []; if ($searchInputs.length === 0) { return; /** * Helper function to get the display label for a menu link item. * * This function constructs a breadcrumb-like label for the given menu * link item by traversing its parent menu items, for example: * Configuration > Development > Performance > Performance * * @param {HTMLLinkElement} item * The link element item to be processed. * * @return {String} * The HTML for the list item to be displayed in the autocomplete * suggestions. */ const getItemDisplayLabel = (item) => { // Start with an empty array of breadcrumbs. const breadcrumbs = []; let parent = item.closest('.menu-item'); // Loop through all the parents with the class 'menu-item'. while (parent) { const link = parent.querySelector( // For each parent menu item, find the first child link without // the CSS class 'admin-toolbar-search-ignore', to give the // ability to ignore certain menu items from the breadcrumb. 'a:first-child:not(.admin-toolbar-search-ignore)', ); if (link) { // Extract the text of the links of the menu items. breadcrumbs.unshift(link.innerText); } // Move to the next parent menu item. parent = parent.parentNode.closest('.menu-item'); } // Return the breadcrumbs joined with ' > ' separator. return breadcrumbs.join(' > '); }; $searchInputs.each(function (index, $searchInput) { $($searchInput).autocomplete({ minLength: 2, position: { collision : 'fit' }, source: function (request, response) { var data = $self.handleAutocomplete(request.term); if (!$self.extraFetched && drupalSettings.adminToolbarSearch.loadExtraLinks) { $.getJSON( Drupal.url('admin/admin-toolbar-search'), function ( data ) { $(data).each(function () { var item = this; item.label = this.labelRaw + ' ' + this.value; $self.links.push(item); }); /** * Function callback for handling the autocomplete search. * * This function filters through the list of menu links collected from * the admin toolbar trays and returns the ones matching the search * query: * The search matches first the exact matches of the whole search * query, then adds suggestions matching all searched keywords (AND). * * Query strings are stripped from link URLs for searching to prevent * generated tokens or destinations from appearing in the results. * * @param {String} term * The search term entered by the user. * * @return {Array} * The list of matched menu links suggestions. */ const handleAutocomplete = (term) => { // Split the search query keywords separated by spaces (' ') into a // list. const keywords = term.split(' '); const suggestions = []; $self.extraFetched = true; menuLinks.forEach((element) => { // Strip query strings from link URLs for searching to prevent generated // tokens or destinations from appearing in the search results. const linkUrl = element.linkUrl.split('?')[0].toLowerCase(); // Concatenate the label and link URL for searching. const label = `${element.label.toLowerCase()} ${linkUrl}`; var results = $self.handleAutocomplete(request.term); response(results); // Try selecting first the exact matches of the whole search query. if (label.indexOf(term.toLowerCase()) >= 0) { suggestions.push(element); } else { // Add suggestions matching *all* searched keywords. let matchCount = 0; keywords.forEach((keyword) => { if (label.indexOf(keyword.toLowerCase()) >= 0) { matchCount += 1; } }); // Keep the suggestion only if *all* keywords matched (AND). if (matchCount === keywords.length) { suggestions.push(element); } else { response(data); } }, open: function () { var zIndex = $('#toolbar-item-administration-tray') .css('z-index') + 1; $(this).autocomplete('widget').css('z-index', zIndex); }); // Return the list of matched menu links suggestions. return suggestions; }; return false; /** * Initialize the autocomplete widget for each search input field. * * The autocomplete widgets use the same menu links array for search * suggestions. * * @see https://api.jqueryui.com/autocomplete/ */ searchInputFields.forEach((searchInputField) => { // Initialize the jQuery UI Autocomplete widget. $(searchInputField) .autocomplete({ // Minimum characters to trigger the autocomplete. minLength: 2, // Position the autocomplete list below the input field. position: { collision: 'fit' }, // Source callback to provide the autocomplete suggestions. source(request, response) { // Call the handleAutocomplete function to get the results. response(handleAutocomplete(request.term)); }, select: function (event, ui) { // Handle the selection of an autocomplete item. select(event, ui) { if (ui.item.value) { location.href = ui.item.value; return false; } // Navigate to the selected link URL. window.location.href = ui.item.linkUrl; } }).data('ui-autocomplete')._renderItem = (function (ul, item) { return false; }, }) // Override the default rendering of the autocomplete list items. .data('ui-autocomplete')._renderItem = (ul, item) => { // Add a custom CSS class to the autocomplete list. ul.addClass('admin-toolbar-search-autocomplete-list'); // Return the formatted list item HTML with link label and URL. return $('<li>') .append('<div ><a href="' + item.value + '">' + item.labelRaw + ' <span class="admin-toolbar-search-url">' + item.value + '</span></a></div>') .append( `<div><a href="${item.linkUrl}">${item.label}</a></div>`, ) .appendTo(ul); }; /** * Initialize the menu links for autocomplete searches. * * Populates the links in admin toolbar search only when the input * fields are focused: * - Collect all the links available in the admin toolbar trays * ('.toolbar-tray') with the drupal custom data attribute: * - 'data-drupal-link-system-path' * - Optionally, fetch extra links through an AJAX call to the * server with a specific controller. */ searchInputField.addEventListener('focus', () => { // Populate only when links array is empty (only the first time). if (menuLinks.length === 0) { // Exclude certain paths from the search results, such as the // front page. const getUrl = window.location; const baseUrl = `${getUrl.protocol}//${getUrl.host}/`; // Define paths to be excluded from the search results. const excludedPaths = [Drupal.url(''), baseUrl]; // Collect all the links available in the admin toolbar trays // ('.toolbar-tray') with the drupal custom data attribute: // - 'data-drupal-link-system-path'. document .querySelectorAll( `.toolbar-tray a[data-drupal-link-system-path]`, ) .forEach((element) => { // Exclude links with URLs matching the excluded paths. if (excludedPaths.includes(element.href)) { return; } // Save each link in the menuLinks array for filtering with // autocomplete. menuLinks.push({ // The link text is used as value to be displayed in the // input field when selected with keyboard. value: element.innerText, // The label is used to display the full path of the link // in the autocomplete suggestions. label: Drupal.checkPlain(getItemDisplayLabel(element)), // The link URL is used for navigation when the item is // selected. linkUrl: element.href, }); }); // Populate the links for search results when the input is pressed. $searchInputs.focus(function () { Drupal.behaviors.adminToolbarSearch.populateLinks($self); }); // Focus the search input field when the search tab is pressed. context.querySelector('#admin-toolbar-search-tab .toolbar-item') .addEventListener('click', (e) => { e.preventDefault(); const searchTabTray = e.target.nextElementSibling; searchTabTray.classList.toggle('is-active'); searchTabTray.querySelector('#admin-toolbar-search-input').focus(); // When the admin toolbar tools module is enabled, support // loading extra links. if (drupalSettings.adminToolbarSearch.loadExtraLinks) { // Optionally, fetch extra links through an AJAX call to the // server with a specific controller returning a JSON array. fetch(Drupal.url('admin/admin-toolbar-search')) .then((response) => response.json()) .then((dataParam) => { // Merge the results of the JSON call into the array of // menu links so it can be searched as well. dataParam.forEach((dataParamItem) => { menuLinks.push({ value: dataParamItem.labelRaw, // The full path is already provided by the controller // so a call to 'getItemDisplayLabel' is not needed. label: Drupal.checkPlain(dataParamItem.labelRaw), linkUrl: dataParamItem.value, }); }); }, getItemLabel: function (item) { var breadcrumbs = []; $(item).parents().each(function () { if ($(this).hasClass('menu-item')) { var $link = $(this).find('a:first'); if ($link.length && !$link.hasClass('admin-toolbar-search-ignore')) { breadcrumbs.unshift($link.text()); } } }); return breadcrumbs.join(' > '); }, handleAutocomplete: function (term) { var $self = this; var keywords = term.split(" "); // Split search terms into list. var suggestions = []; $self.links.forEach(function (element) { var label = element.label.toLowerCase(); // Add exact matches. if (label.indexOf(term.toLowerCase()) >= 0) { suggestions.push(element); } else { // Add suggestions where it matches all search terms. var matchCount = 0; keywords.forEach(function (keyword) { if (label.indexOf(keyword.toLowerCase()) >= 0) { matchCount++; } }); if (matchCount == keywords.length) { suggestions.push(element); } } }); return suggestions; }, /** * Populates the links in admin toolbar search. * Focus the search input field when the search tab is clicked. * * Clicking on the search icon in the toolbar bar toggles the * visibility of the search tray and focuses the search input field * for immediate typing. */ populateLinks: function ($self) { // Populate only when links array is empty (only the first time). if ($self.links.length === 0) { var getUrl = window.location; var baseUrl = getUrl.protocol + "//" + getUrl.host + "/"; $('.toolbar-tray a[data-drupal-link-system-path]').each(function () { if (this.href !== baseUrl) { var label = $self.getItemLabel(this); $self.links.push({ 'value': this.href, 'label': label + ' ' + this.href, 'labelRaw': Drupal.checkPlain(label) }); } toolbarBar // Attach the click event to the search tab 'span' element, since // the tab has no link. .querySelector('#admin-toolbar-search-tab .toolbar-item') .addEventListener('click', (e) => { e.preventDefault(); // Toggle the search tray visibility. const searchTabTray = e.target.nextElementSibling; searchTabTray.classList.toggle('is-active'); // Focus the search input field. searchTabTray.querySelector(searchInputID).focus(); }); } }, ); }, }; })(jQuery, Drupal); })(once, Drupal, jQuery); admin_toolbar_search/tests/src/FunctionalJavascript/AdminToolbarSearchKeyboardShortcutTest.php→admin_toolbar_search/tests/src/FunctionalJavascript/AdminToolbarSearchEventsTest.php +61 −8 Original line number Diff line number Diff line Loading @@ -8,17 +8,19 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\admin_toolbar_search\Constants\AdminToolbarSearchConstants; /** * Test the keyboard shortcut functionality of Admin Toolbar Search. * Test the Javascript events interactions with the Admin Toolbar Search. * * Ensure the search input field is focused when the keyboard shortcut 'Alt + a' * is used. * Ensure the javascript events attached to the module are working as expected: * - The search input field should be focused when the keyboard shortcut * 'Alt + a' is used. * - The search input field should be focused when the search tab is clicked. * * @see admin_toolbar_search/js/admin_toolbar_search.keyboard_shortcut.js * * @group admin_toolbar * @group admin_toolbar_search */ class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase { class AdminToolbarSearchEventsTest extends WebDriverTestBase { /** * A user with access to the Admin Toolbar Search. Loading Loading @@ -77,8 +79,46 @@ class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase { * * Ensure the search input field is focused when the keyboard shortcut * 'Alt + a' is used: * - Call the standard test routine with no event, defaulting to 'keyboard': * Trigger the keyboard shortcut 'Alt + a' to focus on the search input * field. * * @return void * Nothing to return. * * @see doTestAdminToolbarSearchEvents() */ public function testAdminToolbarSearchKeyboardShortcut() { // Call the standard test routine with the default 'keyboard' event. $this->doTestAdminToolbarSearchEvents(); } /** * Test the click event on the search tab functionality. * * Ensure the search input field is focused when the search tab is clicked: * - Call the standard test routine with the 'click' event: * Click on the search tab in the toolbar and focus the input field. * * @return void * Nothing to return. * * @see doTestAdminToolbarSearchEvents() */ public function testAdminToolbarSearchClickFocus() { // Call the standard test routine with the 'click' event. $this->doTestAdminToolbarSearchEvents('click'); } /** * Sub routine to test the javascript events attached to the search field. * * Ensure the search input field is focused when the keyboard shortcut * 'Alt + a' is used or the search tab is clicked: * - Check that the search tray and input field are initially *not* visible. * - Trigger the keyboard shortcut 'Alt + a'. * - Trigger the specified event: * - Focus, by default, with the keyboard shortcut 'Alt + a'. * - Click, if specified, on the search tab in the toolbar. * - Check that the search tray and input field are now visible. * - Check that the search input field has focus. * Loading @@ -86,10 +126,15 @@ class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase { * search input field is displayed in a tray in the toolbar, thus initially * not visible when the page loads. * * @param string $event * The event to trigger to focus the search input field: * - 'keyboard' (default): Trigger the keyboard shortcut 'Alt + a'. * - 'click': Click on the search tab in the toolbar. * * @return void * Nothing to return. */ public function testAdminToolbarSearchKeyboardShortcut() { public function doTestAdminToolbarSearchEvents(string $event = 'keyboard'): void { // Get the current test session. $test_session = $this->getSession(); // Get the current page. Loading @@ -102,14 +147,22 @@ class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase { $this->assertFalse($search_tray_element->isVisible()); $this->assertFalse($search_input_element->isVisible()); // Trigger the keyboard shortcut 'Alt + a' to focus the search input field. if ($event === 'click') { // Click on the search tab to open the toolbar tray and focus the input // field. $page->find('css', '#' . AdminToolbarSearchConstants::ADMIN_TOOLBAR_SEARCH_HTML_IDS['search_tab'] . ' .toolbar-item')->click(); } else { // Trigger the keyboard shortcut 'Alt + a' to focus the search input // field. $test_session->executeScript("document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 65, altKey: true }));"); } // Check the search tray and input field are now visible. $this->assertTrue($search_tray_element->isVisible()); $this->assertTrue($search_input_element->isVisible()); // Check that the search input field has focus. // Check that the search input field *has* focus. $search_input_has_focus = $test_session->evaluateScript('document.activeElement.getAttribute("id") === "' . AdminToolbarSearchConstants::ADMIN_TOOLBAR_SEARCH_HTML_IDS['search_input'] . '";'); $this->assertTrue($search_input_has_focus); } Loading admin_toolbar_search/tests/src/FunctionalJavascript/AdminToolbarToolsSearchTest.php +1 −1 Original line number Diff line number Diff line Loading @@ -185,7 +185,7 @@ class AdminToolbarToolsSearchTest extends AdminToolbarSearchTestBase { // Assert there is only one suggestion with a link to // /admin/structure/types/manage/article/fields. $count = count($suggestions->findAll('xpath', '//span[contains(text(), "/admin/structure/types/manage/article/fields")]')); $count = count($suggestions->findAll('xpath', '//a[contains(@href, "/admin/structure/types/manage/article/fields")]')); $this->assertEquals(1, $count); // Test that bundle within admin toolbar appears in search. Loading Loading
admin_toolbar_search/css/admin_toolbar_search.css +6 −4 Original line number Diff line number Diff line Loading @@ -47,6 +47,12 @@ max-height: 18.75rem; font-size: 0.8125rem; &.ui-autocomplete { /* override core 'toolbar.module.css' class '.toolbar .toolbar-tray' default value of 'z-index:501', to prevent overlaps with other toolbar trays. */ z-index: 600; } /* Styles for each suggestion item in the autocomplete list. */ .ui-menu-item { /* Add a bit of vertical spacing between the list items. */ Loading @@ -68,10 +74,6 @@ color: white; } } /* Hide link URLs in the span tags within autocomplete suggestion items. */ .admin-toolbar-search-url { display: none; } } } Loading
admin_toolbar_search/js/admin_toolbar_search.js +247 −112 Original line number Diff line number Diff line /** * @file * Behaviors for the search widget in the admin toolbar. * Behaviors for the autocomplete search widget in the drupal core toolbar. * * @param {Function} once * The once library used to ensure behaviors are attached only once. * @param {Drupal} Drupal * The Drupal global used to define behaviors. * @param {jQuery} $ * The jQuery library used to instantiate the autocomplete widget. */ (function ($, Drupal) { 'use strict'; ((once, Drupal, $) => { /** * Integrates jQuery UI Autocomplete with Admin Toolbar for searching links. * * @namespace Drupal.behaviors.adminToolbarSearch * * @type {Object} * More specifically, a Drupal behavior. * * @prop {Function} attach * Attaches the behavior to instantiate the jQuery autocomplete search field * in the admin toolbar. * * @see https://api.jqueryui.com/autocomplete/ */ Drupal.behaviors.adminToolbarSearch = { // If extra links have been fetched. extraFetched: false, attach: function (context) { if (context != document) { attach(context) { // Attach only when the whole document is loaded. if (context !== document) { return; } // Search input field ID in toolbar tray and mobile support version. const searchInputID = '#admin-toolbar-search-input'; // Search input field ID in toolbar bar. const searchFieldInputID = '#admin-toolbar-search-field-input'; var $self = this; // Ensure the autocomplete is only called once when the toolbar is loaded. once('admin-toolbar-search', '#toolbar-bar', context).forEach( (toolbarBar) => { // Get the two search input fields on which to call the autocomplete. const searchInputFields = toolbarBar.querySelectorAll( [searchInputID, searchFieldInputID].join(', '), ); const elements = once('admin-toolbar-search', '#toolbar-bar', context); $(elements).each(function () { $self.links = []; // Skip any further processing if no search input field is found. if (searchInputFields.length === 0) { return; } var $searchInputs = $(this).find('#admin-toolbar-search-input, #admin-toolbar-search-field-input'); /** * The list of menu links collected from the admin toolbar trays for * autocomplete searching. * * @param {Array} */ const menuLinks = []; if ($searchInputs.length === 0) { return; /** * Helper function to get the display label for a menu link item. * * This function constructs a breadcrumb-like label for the given menu * link item by traversing its parent menu items, for example: * Configuration > Development > Performance > Performance * * @param {HTMLLinkElement} item * The link element item to be processed. * * @return {String} * The HTML for the list item to be displayed in the autocomplete * suggestions. */ const getItemDisplayLabel = (item) => { // Start with an empty array of breadcrumbs. const breadcrumbs = []; let parent = item.closest('.menu-item'); // Loop through all the parents with the class 'menu-item'. while (parent) { const link = parent.querySelector( // For each parent menu item, find the first child link without // the CSS class 'admin-toolbar-search-ignore', to give the // ability to ignore certain menu items from the breadcrumb. 'a:first-child:not(.admin-toolbar-search-ignore)', ); if (link) { // Extract the text of the links of the menu items. breadcrumbs.unshift(link.innerText); } // Move to the next parent menu item. parent = parent.parentNode.closest('.menu-item'); } // Return the breadcrumbs joined with ' > ' separator. return breadcrumbs.join(' > '); }; $searchInputs.each(function (index, $searchInput) { $($searchInput).autocomplete({ minLength: 2, position: { collision : 'fit' }, source: function (request, response) { var data = $self.handleAutocomplete(request.term); if (!$self.extraFetched && drupalSettings.adminToolbarSearch.loadExtraLinks) { $.getJSON( Drupal.url('admin/admin-toolbar-search'), function ( data ) { $(data).each(function () { var item = this; item.label = this.labelRaw + ' ' + this.value; $self.links.push(item); }); /** * Function callback for handling the autocomplete search. * * This function filters through the list of menu links collected from * the admin toolbar trays and returns the ones matching the search * query: * The search matches first the exact matches of the whole search * query, then adds suggestions matching all searched keywords (AND). * * Query strings are stripped from link URLs for searching to prevent * generated tokens or destinations from appearing in the results. * * @param {String} term * The search term entered by the user. * * @return {Array} * The list of matched menu links suggestions. */ const handleAutocomplete = (term) => { // Split the search query keywords separated by spaces (' ') into a // list. const keywords = term.split(' '); const suggestions = []; $self.extraFetched = true; menuLinks.forEach((element) => { // Strip query strings from link URLs for searching to prevent generated // tokens or destinations from appearing in the search results. const linkUrl = element.linkUrl.split('?')[0].toLowerCase(); // Concatenate the label and link URL for searching. const label = `${element.label.toLowerCase()} ${linkUrl}`; var results = $self.handleAutocomplete(request.term); response(results); // Try selecting first the exact matches of the whole search query. if (label.indexOf(term.toLowerCase()) >= 0) { suggestions.push(element); } else { // Add suggestions matching *all* searched keywords. let matchCount = 0; keywords.forEach((keyword) => { if (label.indexOf(keyword.toLowerCase()) >= 0) { matchCount += 1; } }); // Keep the suggestion only if *all* keywords matched (AND). if (matchCount === keywords.length) { suggestions.push(element); } else { response(data); } }, open: function () { var zIndex = $('#toolbar-item-administration-tray') .css('z-index') + 1; $(this).autocomplete('widget').css('z-index', zIndex); }); // Return the list of matched menu links suggestions. return suggestions; }; return false; /** * Initialize the autocomplete widget for each search input field. * * The autocomplete widgets use the same menu links array for search * suggestions. * * @see https://api.jqueryui.com/autocomplete/ */ searchInputFields.forEach((searchInputField) => { // Initialize the jQuery UI Autocomplete widget. $(searchInputField) .autocomplete({ // Minimum characters to trigger the autocomplete. minLength: 2, // Position the autocomplete list below the input field. position: { collision: 'fit' }, // Source callback to provide the autocomplete suggestions. source(request, response) { // Call the handleAutocomplete function to get the results. response(handleAutocomplete(request.term)); }, select: function (event, ui) { // Handle the selection of an autocomplete item. select(event, ui) { if (ui.item.value) { location.href = ui.item.value; return false; } // Navigate to the selected link URL. window.location.href = ui.item.linkUrl; } }).data('ui-autocomplete')._renderItem = (function (ul, item) { return false; }, }) // Override the default rendering of the autocomplete list items. .data('ui-autocomplete')._renderItem = (ul, item) => { // Add a custom CSS class to the autocomplete list. ul.addClass('admin-toolbar-search-autocomplete-list'); // Return the formatted list item HTML with link label and URL. return $('<li>') .append('<div ><a href="' + item.value + '">' + item.labelRaw + ' <span class="admin-toolbar-search-url">' + item.value + '</span></a></div>') .append( `<div><a href="${item.linkUrl}">${item.label}</a></div>`, ) .appendTo(ul); }; /** * Initialize the menu links for autocomplete searches. * * Populates the links in admin toolbar search only when the input * fields are focused: * - Collect all the links available in the admin toolbar trays * ('.toolbar-tray') with the drupal custom data attribute: * - 'data-drupal-link-system-path' * - Optionally, fetch extra links through an AJAX call to the * server with a specific controller. */ searchInputField.addEventListener('focus', () => { // Populate only when links array is empty (only the first time). if (menuLinks.length === 0) { // Exclude certain paths from the search results, such as the // front page. const getUrl = window.location; const baseUrl = `${getUrl.protocol}//${getUrl.host}/`; // Define paths to be excluded from the search results. const excludedPaths = [Drupal.url(''), baseUrl]; // Collect all the links available in the admin toolbar trays // ('.toolbar-tray') with the drupal custom data attribute: // - 'data-drupal-link-system-path'. document .querySelectorAll( `.toolbar-tray a[data-drupal-link-system-path]`, ) .forEach((element) => { // Exclude links with URLs matching the excluded paths. if (excludedPaths.includes(element.href)) { return; } // Save each link in the menuLinks array for filtering with // autocomplete. menuLinks.push({ // The link text is used as value to be displayed in the // input field when selected with keyboard. value: element.innerText, // The label is used to display the full path of the link // in the autocomplete suggestions. label: Drupal.checkPlain(getItemDisplayLabel(element)), // The link URL is used for navigation when the item is // selected. linkUrl: element.href, }); }); // Populate the links for search results when the input is pressed. $searchInputs.focus(function () { Drupal.behaviors.adminToolbarSearch.populateLinks($self); }); // Focus the search input field when the search tab is pressed. context.querySelector('#admin-toolbar-search-tab .toolbar-item') .addEventListener('click', (e) => { e.preventDefault(); const searchTabTray = e.target.nextElementSibling; searchTabTray.classList.toggle('is-active'); searchTabTray.querySelector('#admin-toolbar-search-input').focus(); // When the admin toolbar tools module is enabled, support // loading extra links. if (drupalSettings.adminToolbarSearch.loadExtraLinks) { // Optionally, fetch extra links through an AJAX call to the // server with a specific controller returning a JSON array. fetch(Drupal.url('admin/admin-toolbar-search')) .then((response) => response.json()) .then((dataParam) => { // Merge the results of the JSON call into the array of // menu links so it can be searched as well. dataParam.forEach((dataParamItem) => { menuLinks.push({ value: dataParamItem.labelRaw, // The full path is already provided by the controller // so a call to 'getItemDisplayLabel' is not needed. label: Drupal.checkPlain(dataParamItem.labelRaw), linkUrl: dataParamItem.value, }); }); }, getItemLabel: function (item) { var breadcrumbs = []; $(item).parents().each(function () { if ($(this).hasClass('menu-item')) { var $link = $(this).find('a:first'); if ($link.length && !$link.hasClass('admin-toolbar-search-ignore')) { breadcrumbs.unshift($link.text()); } } }); return breadcrumbs.join(' > '); }, handleAutocomplete: function (term) { var $self = this; var keywords = term.split(" "); // Split search terms into list. var suggestions = []; $self.links.forEach(function (element) { var label = element.label.toLowerCase(); // Add exact matches. if (label.indexOf(term.toLowerCase()) >= 0) { suggestions.push(element); } else { // Add suggestions where it matches all search terms. var matchCount = 0; keywords.forEach(function (keyword) { if (label.indexOf(keyword.toLowerCase()) >= 0) { matchCount++; } }); if (matchCount == keywords.length) { suggestions.push(element); } } }); return suggestions; }, /** * Populates the links in admin toolbar search. * Focus the search input field when the search tab is clicked. * * Clicking on the search icon in the toolbar bar toggles the * visibility of the search tray and focuses the search input field * for immediate typing. */ populateLinks: function ($self) { // Populate only when links array is empty (only the first time). if ($self.links.length === 0) { var getUrl = window.location; var baseUrl = getUrl.protocol + "//" + getUrl.host + "/"; $('.toolbar-tray a[data-drupal-link-system-path]').each(function () { if (this.href !== baseUrl) { var label = $self.getItemLabel(this); $self.links.push({ 'value': this.href, 'label': label + ' ' + this.href, 'labelRaw': Drupal.checkPlain(label) }); } toolbarBar // Attach the click event to the search tab 'span' element, since // the tab has no link. .querySelector('#admin-toolbar-search-tab .toolbar-item') .addEventListener('click', (e) => { e.preventDefault(); // Toggle the search tray visibility. const searchTabTray = e.target.nextElementSibling; searchTabTray.classList.toggle('is-active'); // Focus the search input field. searchTabTray.querySelector(searchInputID).focus(); }); } }, ); }, }; })(jQuery, Drupal); })(once, Drupal, jQuery);
admin_toolbar_search/tests/src/FunctionalJavascript/AdminToolbarSearchKeyboardShortcutTest.php→admin_toolbar_search/tests/src/FunctionalJavascript/AdminToolbarSearchEventsTest.php +61 −8 Original line number Diff line number Diff line Loading @@ -8,17 +8,19 @@ use Drupal\FunctionalJavascriptTests\WebDriverTestBase; use Drupal\admin_toolbar_search\Constants\AdminToolbarSearchConstants; /** * Test the keyboard shortcut functionality of Admin Toolbar Search. * Test the Javascript events interactions with the Admin Toolbar Search. * * Ensure the search input field is focused when the keyboard shortcut 'Alt + a' * is used. * Ensure the javascript events attached to the module are working as expected: * - The search input field should be focused when the keyboard shortcut * 'Alt + a' is used. * - The search input field should be focused when the search tab is clicked. * * @see admin_toolbar_search/js/admin_toolbar_search.keyboard_shortcut.js * * @group admin_toolbar * @group admin_toolbar_search */ class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase { class AdminToolbarSearchEventsTest extends WebDriverTestBase { /** * A user with access to the Admin Toolbar Search. Loading Loading @@ -77,8 +79,46 @@ class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase { * * Ensure the search input field is focused when the keyboard shortcut * 'Alt + a' is used: * - Call the standard test routine with no event, defaulting to 'keyboard': * Trigger the keyboard shortcut 'Alt + a' to focus on the search input * field. * * @return void * Nothing to return. * * @see doTestAdminToolbarSearchEvents() */ public function testAdminToolbarSearchKeyboardShortcut() { // Call the standard test routine with the default 'keyboard' event. $this->doTestAdminToolbarSearchEvents(); } /** * Test the click event on the search tab functionality. * * Ensure the search input field is focused when the search tab is clicked: * - Call the standard test routine with the 'click' event: * Click on the search tab in the toolbar and focus the input field. * * @return void * Nothing to return. * * @see doTestAdminToolbarSearchEvents() */ public function testAdminToolbarSearchClickFocus() { // Call the standard test routine with the 'click' event. $this->doTestAdminToolbarSearchEvents('click'); } /** * Sub routine to test the javascript events attached to the search field. * * Ensure the search input field is focused when the keyboard shortcut * 'Alt + a' is used or the search tab is clicked: * - Check that the search tray and input field are initially *not* visible. * - Trigger the keyboard shortcut 'Alt + a'. * - Trigger the specified event: * - Focus, by default, with the keyboard shortcut 'Alt + a'. * - Click, if specified, on the search tab in the toolbar. * - Check that the search tray and input field are now visible. * - Check that the search input field has focus. * Loading @@ -86,10 +126,15 @@ class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase { * search input field is displayed in a tray in the toolbar, thus initially * not visible when the page loads. * * @param string $event * The event to trigger to focus the search input field: * - 'keyboard' (default): Trigger the keyboard shortcut 'Alt + a'. * - 'click': Click on the search tab in the toolbar. * * @return void * Nothing to return. */ public function testAdminToolbarSearchKeyboardShortcut() { public function doTestAdminToolbarSearchEvents(string $event = 'keyboard'): void { // Get the current test session. $test_session = $this->getSession(); // Get the current page. Loading @@ -102,14 +147,22 @@ class AdminToolbarSearchKeyboardShortcutTest extends WebDriverTestBase { $this->assertFalse($search_tray_element->isVisible()); $this->assertFalse($search_input_element->isVisible()); // Trigger the keyboard shortcut 'Alt + a' to focus the search input field. if ($event === 'click') { // Click on the search tab to open the toolbar tray and focus the input // field. $page->find('css', '#' . AdminToolbarSearchConstants::ADMIN_TOOLBAR_SEARCH_HTML_IDS['search_tab'] . ' .toolbar-item')->click(); } else { // Trigger the keyboard shortcut 'Alt + a' to focus the search input // field. $test_session->executeScript("document.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 65, altKey: true }));"); } // Check the search tray and input field are now visible. $this->assertTrue($search_tray_element->isVisible()); $this->assertTrue($search_input_element->isVisible()); // Check that the search input field has focus. // Check that the search input field *has* focus. $search_input_has_focus = $test_session->evaluateScript('document.activeElement.getAttribute("id") === "' . AdminToolbarSearchConstants::ADMIN_TOOLBAR_SEARCH_HTML_IDS['search_input'] . '";'); $this->assertTrue($search_input_has_focus); } Loading
admin_toolbar_search/tests/src/FunctionalJavascript/AdminToolbarToolsSearchTest.php +1 −1 Original line number Diff line number Diff line Loading @@ -185,7 +185,7 @@ class AdminToolbarToolsSearchTest extends AdminToolbarSearchTestBase { // Assert there is only one suggestion with a link to // /admin/structure/types/manage/article/fields. $count = count($suggestions->findAll('xpath', '//span[contains(text(), "/admin/structure/types/manage/article/fields")]')); $count = count($suggestions->findAll('xpath', '//a[contains(@href, "/admin/structure/types/manage/article/fields")]')); $this->assertEquals(1, $count); // Test that bundle within admin toolbar appears in search. Loading