diff --git a/js/dropdown.js b/js/dropdown.js index b9f6c56663d3757a50ea0e13300adf5107343dfa..19024756cfb006e439c1456dd6492068d4be9546 100644 --- a/js/dropdown.js +++ b/js/dropdown.js @@ -1,28 +1,79 @@ /** * @file - * Provides a click handler for buttons in dropdown menus. + * Provides an event handler for hidden elements in dropdown menus. */ -(function ($) { - "use strict"; +(function ($, Drupal, Bootstrap) { + 'use strict'; - // Delegates links that are really "actions" (buttons) in the dropdown menu - // to the actual button so it can actually submit the form. - $(document).on('click', 'a[data-target="dropdown-button"]', function (e) { - e.preventDefault(); - e.stopPropagation(); - $(this).parent().find('button').trigger('click'); - }); + /** + * The list of supported events to proxy. + * + * @type {Array} + */ + var events = [ + // MouseEvent. + 'click', 'dblclick', 'mousedown', 'mouseenter', 'mouseleave', 'mouseup', 'mouseover', 'mousemove', 'mouseout', - // Handle buttons that used to be dropbutton links. - // @see \Drupal\bootstrap\Plugin\Preprocess\BootstrapDropdown::preprocessLinks - $(document).on('click', '.btn-group button[data-url], .dropdown button[data-url]', function (e) { - var url = $(this).data('url'); - if (url) { - e.preventDefault(); - e.stopPropagation(); - window.location = url; + // KeyboardEvent. + 'keypress', 'keydown', 'keyup', + + // TouchEvent. + 'touchstart', 'touchend', 'touchmove', 'touchcancel' + ]; + + /** + * Bootstrap dropdown behaviors. + * + * Proxy any dropdown element events that should actually be fired on the + * original target (e.g. button, submits, etc.). This allows any registered + * event callbacks to be fired as they were intended (despite the fact that + * the markup has been changed to work with Bootstrap). + * + * @see \Drupal\bootstrap\Plugin\Preprocess\BootstrapDropdown::preprocessLinks + * + * @type {Drupal~behavior#bootstrapDropdown} + */ + Drupal.behaviors.bootstrapDropdown = { + attach: function (context) { + var elements = context.querySelectorAll('.dropdown [data-dropdown-target]'); + for (var k in elements) { + if (!elements.hasOwnProperty(k)) { + continue; + } + var element = elements[k]; + for (var i = 0, l = events.length; i < l; i++) { + var event = events[i]; + element.removeEventListener(event, this.proxyEvent); + element.addEventListener(event, this.proxyEvent); + } + } + }, + + /** + * Proxy event handler for bootstrap dropdowns. + * + * @param {Event} e + * The event object. + */ + proxyEvent: function (e) { + // Ignore tabbing. + if (e.type.match(/^key/) && (e.which === 9 || e.keyCode === 9)) { + return; + } + var target = e.currentTarget.dataset && e.currentTarget.dataset.dropdownTarget || e.currentTarget.getAttribute('data-dropdown-target'); + if (target) { + e.preventDefault(); + e.stopPropagation(); + var element = target && target !== '#' && document.querySelectorAll(target)[0]; + if (element) { + Bootstrap.simulate(element, e.type, e); + } + else if (Bootstrap.settings.dev && window.console && !e.type.match(/^mouse/)) { + window.console.debug('[Drupal Bootstrap] Could not find a the target:', target); + } + } } - }); + } -})(jQuery); +})(jQuery, Drupal, Drupal.bootstrap); diff --git a/js/drupal.bootstrap.js b/js/drupal.bootstrap.js index 0ebc402a2d83006a44054859ff6dd32526f531e8..332fcdd238408acd961a726a87a3c488ed80c373 100644 --- a/js/drupal.bootstrap.js +++ b/js/drupal.bootstrap.js @@ -8,7 +8,7 @@ * * @namespace */ -(function ($, Drupal) { +(function ($, Drupal, drupalSettings) { 'use strict'; Drupal.bootstrap = { @@ -86,11 +86,90 @@ plugin.Constructor = constructor; var old = $.fn[id]; - plugin.noConflict = function () { $.fn[id] = old; return this; }; + plugin.noConflict = function () { + $.fn[id] = old; + return this; + }; $.fn[id] = plugin; } }; + /** + * Map of supported events by regular expression. + * + * @type {Object} + */ + Drupal.bootstrap.eventMap = { + Event: /^(?:load|unload|abort|error|select|change|submit|reset|focus|blur|resize|scroll)$/, + MouseEvent: /^(?:click|dblclick|mouse(?:down|enter|leave|up|over|move|out))$/, + KeyboardEvent: /^(?:key(?:down|press|up))$/, + TouchEvent: /^(?:touch(?:start|end|move|cancel))$/ + }; + + /** + * Simulates a native event on an element in the browser. + * + * Note: This is a pretty complete modern implementation. If things are quite + * working the way you intend (in older browsers), you may wish to use the + * jQuery.simulate plugin. If it's available, this method will defer to it. + * + * @see https://github.com/jquery/jquery-simulate + * + * @param {HTMLElement} element + * A DOM element to dispatch event on. + * @param {String} type + * The type of event to simulate. + * @param {Object} [options] + * An object of options to pass to the event constructor. Typically, if + * an event is being proxied, you should just pass the original event + * object here. This allows, if the browser supports it, to be a truly + * simulated event. + */ + Drupal.bootstrap.simulate = function (element, type, options) { + // Defer to the jQuery.simulate plugin, if it's available. + if (typeof $.simulate === 'function') { + new $.simulate(element, type, options); + return; + } + var event; + var ctor; + for (var name in Drupal.bootstrap.eventMap) { + if (Drupal.bootstrap.eventMap[name].test(type)) { + ctor = name; + break; + } + } + if (!ctor) { + throw new SyntaxError('Only rudimentary HTMLEvents, KeyboardEvents and MouseEvents are supported: ' + type); + } + var opts = {bubbles: true, cancelable: true}; + if (ctor === 'KeyboardEvent' || ctor === 'MouseEvent') { + $.extend(opts, {ctrlKey: !1, altKey: !1, shiftKey: !1, metaKey: !1}); + } + if (ctor === 'MouseEvent') { + $.extend(opts, {button: 0, pointerX: 0, pointerY: 0, view: window}); + } + if (options) { + $.extend(opts, options); + } + if (typeof window[ctor] === 'function') { + event = new window[ctor](type, opts); + element.dispatchEvent(event); + } + else if (document.createEvent) { + event = document.createEvent(ctor); + event.initEvent(type, opts.bubbles, opts.cancelable); + element.dispatchEvent(event); + } + else if (typeof element.fireEvent === 'function') { + event = $.extend(document.createEventObject(), opts); + element.fireEvent('on' + type, event); + } + else if (typeof element[type]) { + element[type](); + } + }; + /** * Provide jQuery UI like ability to get/set options for Bootstrap plugins. * diff --git a/src/Bootstrap.php b/src/Bootstrap.php index 414c2991aa4f88ac57355781497667e72b946fe4..e6a9547defb12a499cda82bdbf3a4c21a83169de 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -524,7 +524,7 @@ class Bootstrap { $hooks['bootstrap_dropdown'] = [ 'variables' => [ - 'alignment' => NULL, + 'alignment' => 'down', 'attributes' => [], 'items' => [], 'split' => FALSE, diff --git a/src/Plugin/Alter/ThemeSuggestions.php b/src/Plugin/Alter/ThemeSuggestions.php index b5703649a61b482e164fdc58fcb00a3f319cccfe..ce29ba64bf6c19620e02428b7ba37e07a4910c4a 100644 --- a/src/Plugin/Alter/ThemeSuggestions.php +++ b/src/Plugin/Alter/ThemeSuggestions.php @@ -107,10 +107,7 @@ class ThemeSuggestions extends PluginBase implements AlterInterface { protected function alterInput() { if ($this->element && $this->element->isButton()) { $hook = 'input__button'; - if ($this->element->getProperty('dropbutton')) { - $hook .= '__dropdown'; - } - elseif ($this->element->getProperty('split')) { + if ($this->element->getProperty('split')) { $hook .= '__split'; } $this->addSuggestion($hook); diff --git a/src/Plugin/Preprocess/BootstrapDropdown.php b/src/Plugin/Preprocess/BootstrapDropdown.php index 9ca69cbf6049cd8a14eccd3b643b706edb439a24..28e16629eb57ae273e6f50e92a5bd9b2cddc0f53 100644 --- a/src/Plugin/Preprocess/BootstrapDropdown.php +++ b/src/Plugin/Preprocess/BootstrapDropdown.php @@ -10,7 +10,9 @@ use Drupal\bootstrap\Annotation\BootstrapPreprocess; use Drupal\bootstrap\Utility\Element; use Drupal\bootstrap\Utility\Unicode; use Drupal\bootstrap\Utility\Variables; +use Drupal\Component\Utility\Html; use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Url; /** * Pre-processes variables for the "bootstrap_dropdown" theme hook. @@ -53,50 +55,92 @@ class BootstrapDropdown extends PreprocessBase implements PreprocessInterface { $operations = !!Unicode::strpos($variables->theme_hook_original, 'operations'); // Normal dropbutton links are not actually render arrays, convert them. - foreach ($variables->links as &$link) { - if (isset($link['title']) && $link['url']) { + foreach ($variables->links as &$element) { + if (isset($element['title']) && $element['url']) { // Preserve query parameters (if any) - if (!empty($link['query'])) { - $url_query = $link['url']->getOption('query') ?: []; - $link['url']->setOption('query', NestedArray::mergeDeep($url_query , $link['query'])); + if (!empty($element['query'])) { + $url_query = $element['url']->getOption('query') ?: []; + $element['url']->setOption('query', NestedArray::mergeDeep($url_query , $element['query'])); } // Build render array. - $link = [ + $element = [ '#type' => 'link', - '#title' => $link['title'], - '#url' => $link['url'], + '#title' => $element['title'], + '#url' => $element['url'], ]; } } - // Pop off the first link as the "toggle". - $variables->toggle = array_shift($variables->links); - $toggle = Element::create($variables->toggle); - - // Convert any toggle links to a proper button. - if ($toggle->isType('link')) { - $toggle->exchangeArray([ - '#type' => 'button', - '#value' => $toggle->getProperty('title'), - '#attributes' => [ - 'data-url' => $toggle->getProperty('url')->toString(), - ], - ]); - if ($operations) { - $toggle->setButtonSize('btn-xs'); + $items = Element::createStandalone(); + + $primary_action = NULL; + $links = Element::create($variables->links); + + // Iterate over all provided "links". The array may be associative, so + // this cannot rely on the key to be numeric, it must be tracked manually. + $i = -1; + foreach ($links->children(TRUE) as $key => $child) { + $i++; + + // The first item is always the "primary link". + if ($i === 0) { + // Must generate an ID for this child because the toggle will use it. + $child->getProperty('id', $child->getAttribute('id', Html::getUniqueId('dropdown-item'))); + $primary_action = $child->addClass('hidden'); + } + + // If actually a "link", add it to the items array directly. + if ($child->isType('link')) { + $items->$key->link = $child->getArrayCopy(); + } + // Otherwise, convert into a proper link. + else { + // Hide the original element + $items->$key->element = $child->addClass('hidden')->getArrayCopy(); + + // Retrieve any set HTML identifier for the link, generating a new + // one if necessary. + $id = $child->getProperty('id', $child->getAttribute('id', Html::getUniqueId('dropdown-item'))); + $items->$key->link = Element::createStandalone([ + '#type' => 'link', + '#title' => $child->getProperty('value', $child->getProperty('title', $child->getProperty('text'))), + '#url' => Url::fromUserInput('#'), + '#attributes' => ['data-dropdown-target' => "#$id"], + ]); + + // Also hide the real link if it's the primary action. + if ($i === 0) { + $items->$key->link->addClass('hidden'); + } } } - // Remove the dropbutton property. - else { - $toggle->unsetProperty('dropbutton'); + + // Create a toggle button, extracting relevant info from primary action. + $toggle = Element::createStandalone([ + '#type' => 'button', + '#attributes' => $primary_action->getAttributes()->getArrayCopy(), + '#value' => $primary_action->getProperty('value', $primary_action->getProperty('title', $primary_action->getProperty('text'))), + ]); + + // Remove the "hidden" class that was added to the primary action. + $toggle->removeClass('hidden')->removeAttribute('id')->setAttribute('data-dropdown-target', '#' . $primary_action->getAttribute('id')); + + // Make operations smaller. + if ($operations) { + $toggle->setButtonSize('btn-xs', FALSE); } - $variables->items = array_values($variables->links); + // Add the toggle render array to the variables. + $variables->toggle = $toggle->getArrayCopy(); // Determine if toggle should be a split button. - $variables->split = !!$variables->links; + $variables->split = count($items) > 1; + + // Add the items variable for "bootstrap_dropdown". + $variables->items = $items->getArrayCopy(); + // Remove the unnecessary "links" variable now. unset($variables->links); } } diff --git a/src/Theme.php b/src/Theme.php index 480a0b0e7a8b27172dec978109e2a7044f7d4bf8..aec2c6059793c51003be3030c92458ce44502ce3 100644 --- a/src/Theme.php +++ b/src/Theme.php @@ -247,7 +247,15 @@ class Theme { } $cache->setMultiple($drupal_settings); } - return array_intersect_key($this->settings()->get(), $drupal_settings); + + $drupal_settings = array_intersect_key($this->settings()->get(), $drupal_settings); + + // Indicate that theme is in dev mode. + if ($this->isDev()) { + $drupal_settings['dev'] = TRUE; + } + + return $drupal_settings; } /** diff --git a/src/Utility/Attributes.php b/src/Utility/Attributes.php index 3352a7ade88ab190a62a6e905a29e07096ab04a5..91bc2cb8d9b318d6c175f26fd82fc6e022e42ce0 100644 --- a/src/Utility/Attributes.php +++ b/src/Utility/Attributes.php @@ -82,16 +82,20 @@ class Attributes extends ArrayObject { /** * Indicates whether a class is present in the array. * - * @param string $class - * The class to search for. + * @param string|array $class + * The class or array of classes to search for. + * @param bool $all + * Flag determining to check if all classes are present. * * @return bool * TRUE or FALSE * * @see \Drupal\bootstrap\Utility\Attributes::getClasses() */ - public function hasClass($class) { - return array_search($class, $this->getClasses()) !== FALSE; + public function hasClass($class, $all = FALSE) { + $classes = (array) $class; + $result = array_intersect($classes, $this->getClasses()); + return $all ? $result && count($classes) === count($result) : !!$result; } /** diff --git a/src/Utility/Element.php b/src/Utility/Element.php index 2cff16c48bd0b5dfbabd6b05fae46e220f731bf4..84df3f77351de0d6bef30d7063bbef089e0d9408 100644 --- a/src/Utility/Element.php +++ b/src/Utility/Element.php @@ -206,9 +206,12 @@ class Element extends DrupalAttributes { /** * Adds a specific Bootstrap class to color a button based on its text value. * + * @param bool $override + * Flag determining whether or not to override any existing set class. + * * @return $this */ - public function colorize() { + public function colorize($override = TRUE) { $button = $this->isButton(); // @todo refactor this more so it's not just "button" specific. @@ -226,7 +229,7 @@ class Element extends DrupalAttributes { $class = $button && !Bootstrap::getTheme()->getSetting('button_colorize') ? 'btn-default' : FALSE; // Search for an existing class. - if (!$class) { + if (!$class || !$override) { foreach ($classes as $value) { if ($this->hasClass($value)) { $class = $value; @@ -573,10 +576,13 @@ class Element extends DrupalAttributes { * @param string $class * The full button size class to add. If none is provided, it will default * to any set theme setting. + * @param bool $override + * Flag indicating if the passed $class should be forcibly set. Setting + * this to FALSE allows any existing set class to persist. * * @return $this */ - public function setButtonSize($class = NULL) { + public function setButtonSize($class = NULL, $override = TRUE) { // Immediately return if element is not a button. if (!$this->isButton()) { return $this; @@ -592,7 +598,7 @@ class Element extends DrupalAttributes { } // Search for an existing class. - if (!$class) { + if (!$class || !$override) { foreach ($classes as $value) { if ($this->hasClass($value)) { $class = $value; diff --git a/templates/bootstrap/bootstrap-dropdown.html.twig b/templates/bootstrap/bootstrap-dropdown.html.twig index 4fc5dc068d427f30282f1fc887bc5f7224f87b70..e6ae2e4f9a1b286211ec39ec952dcbded52bf395 100644 --- a/templates/bootstrap/bootstrap-dropdown.html.twig +++ b/templates/bootstrap/bootstrap-dropdown.html.twig @@ -16,7 +16,7 @@ {% set classes = [ 'btn-group', - alignment == 'up' ? 'dropup', + alignment ? 'drop' ~ alignment|clean_class : 'dropdown', ] %} diff --git a/templates/input/input--button--dropdown.html.twig b/templates/input/input--button--dropdown.html.twig deleted file mode 100644 index de7c4e6a7ae8495ade6458e1540156404d50486d..0000000000000000000000000000000000000000 --- a/templates/input/input--button--dropdown.html.twig +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "input--button.html.twig" %} -{# -/** - * @file - * Theme suggestion for "button__dropdown" input form element. - * - * Available variables: - * - attributes: A list of HTML attributes for the input element. - * - children: Optional additional rendered elements. - * - icon: An icon. - * - icon_only: Flag to display only the icon and not the label. - * - icon_position: Where an icon should be displayed. - * - label: button label. - * - prefix: Markup to display before the input element. - * - suffix: Markup to display after the input element. - * - type: The type of input. - * - * @ingroup templates - * - * @see \Drupal\bootstrap\Plugin\Preprocess\InputButton - * @see \Drupal\bootstrap\Plugin\Preprocess\Input - * @see template_preprocess_input() - */ -#} -{% spaceless %} - {% block input %} - {{ label }} - {{ label }} - {{ children }} - {% endblock %} -{% endspaceless %}