refreshless-8.x-1.x-dev/modules/refreshless_turbo/js/overrides/dropbutton.js
modules/refreshless_turbo/js/overrides/dropbutton.js
/**
* @file
* Drupal dropbutton replacement for RefreshLess.
*
* The biggest issue this solves is that core's dropbutton fails to open when
* revisiting a cached page with Turbo, most likely because core's dropbutton
* binds a delegated click handler on the <body> and does not detach it. While
* this could be solved indirectly, actually doing so reliably wasn't working,
* so a rewrite was easier than trying to fix the mess the lack of detach was
* causing.
*
* Note that anything that's exposed by this must maintain specific names to
* ensure maximum compatibility with core and contrib.
*
* @todo Expose Drupal.DropButton and Drupal.DropButton.dropbuttons if anything
* actually uses them. If so, use a Proxy object to keep things separated.
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
*
* @see https://stackoverflow.com/questions/69860820/is-there-a-way-to-proxy-intercept-all-methods-of-a-class-in-javascript/69860903#69860903
* Details returning a Proxy object in a class constructor which is apparently
* valid.
*/
(function(html, Drupal, $, once) {
'use strict';
/**
* Our event namespace.
*
* This should be 'dropbutton' for backwards compatibility with core and
* contrib.
*
* @type {String}
*
* @see https://learn.jquery.com/events/event-basics/#namespacing-events
*/
const eventNamespace = 'dropbutton';
/**
* once() identfier.
*
* Same naming restriction as with eventNamespace.
*
* @type {String}
*/
const onceName = eventNamespace;
/**
* Property name on dropbutton elements that we save our instance to.
*
* @type {String}
*/
const propName = 'drupalDropButton';
/**
* Represents a JavaScript-enhanced dropbutton.
*/
class DropButton {
/**
* The dropbutton element root.
*
* @type {jQuery}
*/
#$element;
/**
* The list element containing all dropbutton actions.
*
* @type {jQuery}
*/
#$list;
/**
* All dropbutton actions.
*
* @type {jQuery}
*/
#$actions;
/**
* The primary dropbutton action.
*
* @type {jQuery}
*/
#$primaryAction;
/**
* The secondary dropbutton actions, if any.
*
* @type {jQuery}
*/
#$secondaryActions;
/**
* The dropbutton toggle element, if any.
*
* @type {jQuery}
*/
#$toggle = $();
/**
* Time in milliseconds to delay closing the dropbutton.
*
* @type {Number}
*/
#closeDelay = 500;
/**
* setTimeout() identifier, if any.
*
* @type {Number|Null}
*/
#timerId = null;
/**
* Dropbutton options object.
*
* @type {Object}
*/
#options;
constructor(element, settings) {
this.#$element = $(element);
this.#$list = this.#$element.find('.dropbutton');
this.#$actions = this.#$list.find('li').addClass('dropbutton-action');
this.#$primaryAction = this.#$actions.first();
this.#$secondaryActions = this.#$actions.not(
this.#$primaryAction,
).addClass('secondary-action');
// Merge defaults with settings.
this.#options = $.extend(
{ title: Drupal.t('List additional actions') },
settings,
);
this.#$element.addClass(
this.hasMultiple() ? 'dropbutton-multiple' : 'dropbutton-single'
);
if (this.hasMultiple()) {
this.#$toggle = $(Drupal.theme(
'dropbuttonToggle', this.#options,
)).insertAfter(this.#$primaryAction);
}
this.#bindEventHandlers();
}
/**
* Destroy this instance by detaching from the element.
*/
destroy() {
this.#unbindEventHandlers();
this.#$element.removeClass(['dropbutton-multiple', 'dropbutton-single']);
this.#$actions.removeClass(['dropbutton-action', 'secondary-action']);
this.#$toggle.remove();
}
/**
* Whether this dropbutton has multiple actions.
*
* @return {Boolean}
* True if the dropbutton has more than one action, and false if it has
* only one action.
*/
hasMultiple() {
return this.#$actions.length > 1;
}
/**
* Bind all of our event handlers.
*/
#bindEventHandlers() {
if (!this.hasMultiple()) {
return;
}
this.#$element.on({
[`mouseenter.${eventNamespace}`]: (event) => {
this.#openHandler(event);
},
[`mouseleave.${eventNamespace}`]: (event) => {
this.#closeHandler(event);
},
[`focusin.${eventNamespace}`]: (event) => {
this.#openHandler(event);
},
[`focusout.${eventNamespace}`]: (event) => {
this.#closeHandler(event);
},
});
this.#$toggle.on(`click.${eventNamespace}`, (event) => {
this.#toggleClickHandler(event);
});
}
/**
* Unbind all of our event handlers.
*/
#unbindEventHandlers() {
if (!this.hasMultiple()) {
return;
}
this.#$element.add(this.#$toggle).off(`.${eventNamespace}`);
}
/**
* Open event handler.
*
* @param {jQuery.Event} event
*/
#openHandler(event) {
if (typeof this.#timerId !== 'null') {
window.clearTimeout(this.#timerId);
}
}
/**
* Close event handler.
*
* @param {jQuery.Event} event
*/
#closeHandler(event) {
this.#timerId = window.setTimeout(() => {
this.close();
}, this.#closeDelay);
}
/**
* Toggle click event handler.
*
* @param {jQuery.Event} event
*/
#toggleClickHandler(event) {
this.toggle();
event.preventDefault();
}
/**
* Whether the dropbutton menu is currently open.
*
* @return {Boolean}
* True if the dropbutton menu is currently open, false otherwise.
*/
isOpen() {
return this.#$element.hasClass('open');
}
/**
* Toggle this dropbutton open and closed.
*
* @param {boolean} show
* Force the dropbutton to open by passing true or to close by passing
* false.
*/
toggle(show) {
const isBool = typeof show === 'boolean';
show = isBool ? show : !this.isOpen();
this.#$element.toggleClass('open', show);
}
/**
* Open this dropbutton.
*/
open() {
this.toggle(true);
}
/**
* Close this dropbutton.
*/
close() {
this.toggle(false);
}
get $dropbutton() {
return this.#$element;
}
get $toggle() {
return this.#$toggle;
}
get $list() {
return this.#$list;
}
get $actions() {
return this.#$actions;
}
get $primaryAction() {
return this.#$primaryAction;
}
get $secondaryActions() {
return this.#$secondaryActions;
}
}
/**
* Dropbutton toggle element theme implementation.
*
* @param {Object} options
* Options object.
* @param {String} [options.title]
* The button text.
*
* @return {String}
* A string representing a DOM fragment.
*/
Drupal.theme.dropbuttonToggle = (options) => {
return `<li class="dropbutton-toggle"><button type="button"><span class="dropbutton-arrow"><span class="visually-hidden">${options.title}</span></span></button></li>`;
};
Drupal.behaviors.dropButton = {
attach(context, settings) {
$(once(onceName, '.dropbutton-wrapper', context)).each((i, element) => {
$(element).prop(propName, new DropButton(element, settings.dropbutton));
});
},
detach(context, settings, trigger) {
$(once.remove(
onceName, '.dropbutton-wrapper', context,
)).each((i, element) => {
$(element).prop(propName)?.destroy();
$(element).removeProp(propName);
});
},
};
})(document.documentElement, Drupal, jQuery, once);
