618 lines
18 KiB
JavaScript
618 lines
18 KiB
JavaScript
(function($, anim) {
|
|
'use strict';
|
|
|
|
let _defaults = {
|
|
alignment: 'left',
|
|
autoFocus: true,
|
|
constrainWidth: true,
|
|
container: null,
|
|
coverTrigger: true,
|
|
closeOnClick: true,
|
|
hover: false,
|
|
inDuration: 150,
|
|
outDuration: 250,
|
|
onOpenStart: null,
|
|
onOpenEnd: null,
|
|
onCloseStart: null,
|
|
onCloseEnd: null,
|
|
onItemClick: null
|
|
};
|
|
|
|
/**
|
|
* @class
|
|
*/
|
|
class Dropdown extends Component {
|
|
constructor(el, options) {
|
|
super(Dropdown, el, options);
|
|
|
|
this.el.M_Dropdown = this;
|
|
Dropdown._dropdowns.push(this);
|
|
|
|
this.id = M.getIdFromTrigger(el);
|
|
this.dropdownEl = document.getElementById(this.id);
|
|
this.$dropdownEl = $(this.dropdownEl);
|
|
|
|
/**
|
|
* Options for the dropdown
|
|
* @member Dropdown#options
|
|
* @prop {String} [alignment='left'] - Edge which the dropdown is aligned to
|
|
* @prop {Boolean} [autoFocus=true] - Automatically focus dropdown el for keyboard
|
|
* @prop {Boolean} [constrainWidth=true] - Constrain width to width of the button
|
|
* @prop {Element} container - Container element to attach dropdown to (optional)
|
|
* @prop {Boolean} [coverTrigger=true] - Place dropdown over trigger
|
|
* @prop {Boolean} [closeOnClick=true] - Close on click of dropdown item
|
|
* @prop {Boolean} [hover=false] - Open dropdown on hover
|
|
* @prop {Number} [inDuration=150] - Duration of open animation in ms
|
|
* @prop {Number} [outDuration=250] - Duration of close animation in ms
|
|
* @prop {Function} onOpenStart - Function called when dropdown starts opening
|
|
* @prop {Function} onOpenEnd - Function called when dropdown finishes opening
|
|
* @prop {Function} onCloseStart - Function called when dropdown starts closing
|
|
* @prop {Function} onCloseEnd - Function called when dropdown finishes closing
|
|
*/
|
|
this.options = $.extend({}, Dropdown.defaults, options);
|
|
|
|
/**
|
|
* Describes open/close state of dropdown
|
|
* @type {Boolean}
|
|
*/
|
|
this.isOpen = false;
|
|
|
|
/**
|
|
* Describes if dropdown content is scrollable
|
|
* @type {Boolean}
|
|
*/
|
|
this.isScrollable = false;
|
|
|
|
/**
|
|
* Describes if touch moving on dropdown content
|
|
* @type {Boolean}
|
|
*/
|
|
this.isTouchMoving = false;
|
|
|
|
this.focusedIndex = -1;
|
|
this.filterQuery = [];
|
|
|
|
// Move dropdown-content after dropdown-trigger
|
|
if (!!this.options.container) {
|
|
$(this.options.container).append(this.dropdownEl);
|
|
} else {
|
|
this.$el.after(this.dropdownEl);
|
|
}
|
|
|
|
this._makeDropdownFocusable();
|
|
this._resetFilterQueryBound = this._resetFilterQuery.bind(this);
|
|
this._handleDocumentClickBound = this._handleDocumentClick.bind(this);
|
|
this._handleDocumentTouchmoveBound = this._handleDocumentTouchmove.bind(this);
|
|
this._handleDropdownClickBound = this._handleDropdownClick.bind(this);
|
|
this._handleDropdownKeydownBound = this._handleDropdownKeydown.bind(this);
|
|
this._handleTriggerKeydownBound = this._handleTriggerKeydown.bind(this);
|
|
this._setupEventHandlers();
|
|
}
|
|
|
|
static get defaults() {
|
|
return _defaults;
|
|
}
|
|
|
|
static init(els, options) {
|
|
return super.init(this, els, options);
|
|
}
|
|
|
|
/**
|
|
* Get Instance
|
|
*/
|
|
static getInstance(el) {
|
|
let domElem = !!el.jquery ? el[0] : el;
|
|
return domElem.M_Dropdown;
|
|
}
|
|
|
|
/**
|
|
* Teardown component
|
|
*/
|
|
destroy() {
|
|
this._resetDropdownStyles();
|
|
this._removeEventHandlers();
|
|
Dropdown._dropdowns.splice(Dropdown._dropdowns.indexOf(this), 1);
|
|
this.el.M_Dropdown = undefined;
|
|
}
|
|
|
|
/**
|
|
* Setup Event Handlers
|
|
*/
|
|
_setupEventHandlers() {
|
|
// Trigger keydown handler
|
|
this.el.addEventListener('keydown', this._handleTriggerKeydownBound);
|
|
|
|
// Item click handler
|
|
this.dropdownEl.addEventListener('click', this._handleDropdownClickBound);
|
|
|
|
// Hover event handlers
|
|
if (this.options.hover) {
|
|
this._handleMouseEnterBound = this._handleMouseEnter.bind(this);
|
|
this.el.addEventListener('mouseenter', this._handleMouseEnterBound);
|
|
this._handleMouseLeaveBound = this._handleMouseLeave.bind(this);
|
|
this.el.addEventListener('mouseleave', this._handleMouseLeaveBound);
|
|
this.dropdownEl.addEventListener('mouseleave', this._handleMouseLeaveBound);
|
|
|
|
// Click event handlers
|
|
} else {
|
|
this._handleClickBound = this._handleClick.bind(this);
|
|
this.el.addEventListener('click', this._handleClickBound);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove Event Handlers
|
|
*/
|
|
_removeEventHandlers() {
|
|
this.el.removeEventListener('keydown', this._handleTriggerKeydownBound);
|
|
this.dropdownEl.removeEventListener('click', this._handleDropdownClickBound);
|
|
|
|
if (this.options.hover) {
|
|
this.el.removeEventListener('mouseenter', this._handleMouseEnterBound);
|
|
this.el.removeEventListener('mouseleave', this._handleMouseLeaveBound);
|
|
this.dropdownEl.removeEventListener('mouseleave', this._handleMouseLeaveBound);
|
|
} else {
|
|
this.el.removeEventListener('click', this._handleClickBound);
|
|
}
|
|
}
|
|
|
|
_setupTemporaryEventHandlers() {
|
|
// Use capture phase event handler to prevent click
|
|
document.body.addEventListener('click', this._handleDocumentClickBound, true);
|
|
document.body.addEventListener('touchend', this._handleDocumentClickBound);
|
|
document.body.addEventListener('touchmove', this._handleDocumentTouchmoveBound);
|
|
this.dropdownEl.addEventListener('keydown', this._handleDropdownKeydownBound);
|
|
}
|
|
|
|
_removeTemporaryEventHandlers() {
|
|
// Use capture phase event handler to prevent click
|
|
document.body.removeEventListener('click', this._handleDocumentClickBound, true);
|
|
document.body.removeEventListener('touchend', this._handleDocumentClickBound);
|
|
document.body.removeEventListener('touchmove', this._handleDocumentTouchmoveBound);
|
|
this.dropdownEl.removeEventListener('keydown', this._handleDropdownKeydownBound);
|
|
}
|
|
|
|
_handleClick(e) {
|
|
e.preventDefault();
|
|
this.open();
|
|
}
|
|
|
|
_handleMouseEnter() {
|
|
this.open();
|
|
}
|
|
|
|
_handleMouseLeave(e) {
|
|
let toEl = e.toElement || e.relatedTarget;
|
|
let leaveToDropdownContent = !!$(toEl).closest('.dropdown-content').length;
|
|
let leaveToActiveDropdownTrigger = false;
|
|
|
|
let $closestTrigger = $(toEl).closest('.dropdown-trigger');
|
|
if (
|
|
$closestTrigger.length &&
|
|
!!$closestTrigger[0].M_Dropdown &&
|
|
$closestTrigger[0].M_Dropdown.isOpen
|
|
) {
|
|
leaveToActiveDropdownTrigger = true;
|
|
}
|
|
|
|
// Close hover dropdown if mouse did not leave to either active dropdown-trigger or dropdown-content
|
|
if (!leaveToActiveDropdownTrigger && !leaveToDropdownContent) {
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
_handleDocumentClick(e) {
|
|
let $target = $(e.target);
|
|
if (
|
|
this.options.closeOnClick &&
|
|
$target.closest('.dropdown-content').length &&
|
|
!this.isTouchMoving
|
|
) {
|
|
// isTouchMoving to check if scrolling on mobile.
|
|
setTimeout(() => {
|
|
this.close();
|
|
}, 0);
|
|
} else if (
|
|
$target.closest('.dropdown-trigger').length ||
|
|
!$target.closest('.dropdown-content').length
|
|
) {
|
|
setTimeout(() => {
|
|
this.close();
|
|
}, 0);
|
|
}
|
|
this.isTouchMoving = false;
|
|
}
|
|
|
|
_handleTriggerKeydown(e) {
|
|
// ARROW DOWN OR ENTER WHEN SELECT IS CLOSED - open Dropdown
|
|
if ((e.which === M.keys.ARROW_DOWN || e.which === M.keys.ENTER) && !this.isOpen) {
|
|
e.preventDefault();
|
|
this.open();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Document Touchmove
|
|
* @param {Event} e
|
|
*/
|
|
_handleDocumentTouchmove(e) {
|
|
let $target = $(e.target);
|
|
if ($target.closest('.dropdown-content').length) {
|
|
this.isTouchMoving = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Dropdown Click
|
|
* @param {Event} e
|
|
*/
|
|
_handleDropdownClick(e) {
|
|
// onItemClick callback
|
|
if (typeof this.options.onItemClick === 'function') {
|
|
let itemEl = $(e.target).closest('li')[0];
|
|
this.options.onItemClick.call(this, itemEl);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Dropdown Keydown
|
|
* @param {Event} e
|
|
*/
|
|
_handleDropdownKeydown(e) {
|
|
if (e.which === M.keys.TAB) {
|
|
e.preventDefault();
|
|
this.close();
|
|
|
|
// Navigate down dropdown list
|
|
} else if ((e.which === M.keys.ARROW_DOWN || e.which === M.keys.ARROW_UP) && this.isOpen) {
|
|
e.preventDefault();
|
|
let direction = e.which === M.keys.ARROW_DOWN ? 1 : -1;
|
|
let newFocusedIndex = this.focusedIndex;
|
|
let foundNewIndex = false;
|
|
do {
|
|
newFocusedIndex = newFocusedIndex + direction;
|
|
|
|
if (
|
|
!!this.dropdownEl.children[newFocusedIndex] &&
|
|
this.dropdownEl.children[newFocusedIndex].tabIndex !== -1
|
|
) {
|
|
foundNewIndex = true;
|
|
break;
|
|
}
|
|
} while (newFocusedIndex < this.dropdownEl.children.length && newFocusedIndex >= 0);
|
|
|
|
if (foundNewIndex) {
|
|
this.focusedIndex = newFocusedIndex;
|
|
this._focusFocusedItem();
|
|
}
|
|
|
|
// ENTER selects choice on focused item
|
|
} else if (e.which === M.keys.ENTER && this.isOpen) {
|
|
// Search for <a> and <button>
|
|
let focusedElement = this.dropdownEl.children[this.focusedIndex];
|
|
let $activatableElement = $(focusedElement)
|
|
.find('a, button')
|
|
.first();
|
|
|
|
// Click a or button tag if exists, otherwise click li tag
|
|
if (!!$activatableElement.length) {
|
|
$activatableElement[0].click();
|
|
} else if (!!focusedElement) {
|
|
focusedElement.click();
|
|
}
|
|
|
|
// Close dropdown on ESC
|
|
} else if (e.which === M.keys.ESC && this.isOpen) {
|
|
e.preventDefault();
|
|
this.close();
|
|
}
|
|
|
|
// CASE WHEN USER TYPE LETTERS
|
|
let letter = String.fromCharCode(e.which).toLowerCase(),
|
|
nonLetters = [9, 13, 27, 38, 40];
|
|
if (letter && nonLetters.indexOf(e.which) === -1) {
|
|
this.filterQuery.push(letter);
|
|
|
|
let string = this.filterQuery.join(''),
|
|
newOptionEl = $(this.dropdownEl)
|
|
.find('li')
|
|
.filter((el) => {
|
|
return (
|
|
$(el)
|
|
.text()
|
|
.toLowerCase()
|
|
.indexOf(string) === 0
|
|
);
|
|
})[0];
|
|
|
|
if (newOptionEl) {
|
|
this.focusedIndex = $(newOptionEl).index();
|
|
this._focusFocusedItem();
|
|
}
|
|
}
|
|
|
|
this.filterTimeout = setTimeout(this._resetFilterQueryBound, 1000);
|
|
}
|
|
|
|
/**
|
|
* Setup dropdown
|
|
*/
|
|
_resetFilterQuery() {
|
|
this.filterQuery = [];
|
|
}
|
|
|
|
_resetDropdownStyles() {
|
|
this.$dropdownEl.css({
|
|
display: '',
|
|
width: '',
|
|
height: '',
|
|
left: '',
|
|
top: '',
|
|
'transform-origin': '',
|
|
transform: '',
|
|
opacity: ''
|
|
});
|
|
}
|
|
|
|
_makeDropdownFocusable() {
|
|
// Needed for arrow key navigation
|
|
this.dropdownEl.tabIndex = 0;
|
|
|
|
// Only set tabindex if it hasn't been set by user
|
|
$(this.dropdownEl)
|
|
.children()
|
|
.each(function(el) {
|
|
if (!el.getAttribute('tabindex')) {
|
|
el.setAttribute('tabindex', 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
_focusFocusedItem() {
|
|
if (
|
|
this.focusedIndex >= 0 &&
|
|
this.focusedIndex < this.dropdownEl.children.length &&
|
|
this.options.autoFocus
|
|
) {
|
|
this.dropdownEl.children[this.focusedIndex].focus();
|
|
}
|
|
}
|
|
|
|
_getDropdownPosition() {
|
|
let offsetParentBRect = this.el.offsetParent.getBoundingClientRect();
|
|
let triggerBRect = this.el.getBoundingClientRect();
|
|
let dropdownBRect = this.dropdownEl.getBoundingClientRect();
|
|
|
|
let idealHeight = dropdownBRect.height;
|
|
let idealWidth = dropdownBRect.width;
|
|
let idealXPos = triggerBRect.left - dropdownBRect.left;
|
|
let idealYPos = triggerBRect.top - dropdownBRect.top;
|
|
|
|
let dropdownBounds = {
|
|
left: idealXPos,
|
|
top: idealYPos,
|
|
height: idealHeight,
|
|
width: idealWidth
|
|
};
|
|
|
|
// Countainer here will be closest ancestor with overflow: hidden
|
|
let closestOverflowParent = !!this.dropdownEl.offsetParent
|
|
? this.dropdownEl.offsetParent
|
|
: this.dropdownEl.parentNode;
|
|
|
|
let alignments = M.checkPossibleAlignments(
|
|
this.el,
|
|
closestOverflowParent,
|
|
dropdownBounds,
|
|
this.options.coverTrigger ? 0 : triggerBRect.height
|
|
);
|
|
|
|
let verticalAlignment = 'top';
|
|
let horizontalAlignment = this.options.alignment;
|
|
idealYPos += this.options.coverTrigger ? 0 : triggerBRect.height;
|
|
|
|
// Reset isScrollable
|
|
this.isScrollable = false;
|
|
|
|
if (!alignments.top) {
|
|
if (alignments.bottom) {
|
|
verticalAlignment = 'bottom';
|
|
} else {
|
|
this.isScrollable = true;
|
|
|
|
// Determine which side has most space and cutoff at correct height
|
|
if (alignments.spaceOnTop > alignments.spaceOnBottom) {
|
|
verticalAlignment = 'bottom';
|
|
idealHeight += alignments.spaceOnTop;
|
|
idealYPos -= alignments.spaceOnTop;
|
|
} else {
|
|
idealHeight += alignments.spaceOnBottom;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If preferred horizontal alignment is possible
|
|
if (!alignments[horizontalAlignment]) {
|
|
let oppositeAlignment = horizontalAlignment === 'left' ? 'right' : 'left';
|
|
if (alignments[oppositeAlignment]) {
|
|
horizontalAlignment = oppositeAlignment;
|
|
} else {
|
|
// Determine which side has most space and cutoff at correct height
|
|
if (alignments.spaceOnLeft > alignments.spaceOnRight) {
|
|
horizontalAlignment = 'right';
|
|
idealWidth += alignments.spaceOnLeft;
|
|
idealXPos -= alignments.spaceOnLeft;
|
|
} else {
|
|
horizontalAlignment = 'left';
|
|
idealWidth += alignments.spaceOnRight;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (verticalAlignment === 'bottom') {
|
|
idealYPos =
|
|
idealYPos - dropdownBRect.height + (this.options.coverTrigger ? triggerBRect.height : 0);
|
|
}
|
|
if (horizontalAlignment === 'right') {
|
|
idealXPos = idealXPos - dropdownBRect.width + triggerBRect.width;
|
|
}
|
|
return {
|
|
x: idealXPos,
|
|
y: idealYPos,
|
|
verticalAlignment: verticalAlignment,
|
|
horizontalAlignment: horizontalAlignment,
|
|
height: idealHeight,
|
|
width: idealWidth
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Animate in dropdown
|
|
*/
|
|
_animateIn() {
|
|
anim.remove(this.dropdownEl);
|
|
anim({
|
|
targets: this.dropdownEl,
|
|
opacity: {
|
|
value: [0, 1],
|
|
easing: 'easeOutQuad'
|
|
},
|
|
scaleX: [0.3, 1],
|
|
scaleY: [0.3, 1],
|
|
duration: this.options.inDuration,
|
|
easing: 'easeOutQuint',
|
|
complete: (anim) => {
|
|
if (this.options.autoFocus) {
|
|
this.dropdownEl.focus();
|
|
}
|
|
|
|
// onOpenEnd callback
|
|
if (typeof this.options.onOpenEnd === 'function') {
|
|
this.options.onOpenEnd.call(this, this.el);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Animate out dropdown
|
|
*/
|
|
_animateOut() {
|
|
anim.remove(this.dropdownEl);
|
|
anim({
|
|
targets: this.dropdownEl,
|
|
opacity: {
|
|
value: 0,
|
|
easing: 'easeOutQuint'
|
|
},
|
|
scaleX: 0.3,
|
|
scaleY: 0.3,
|
|
duration: this.options.outDuration,
|
|
easing: 'easeOutQuint',
|
|
complete: (anim) => {
|
|
this._resetDropdownStyles();
|
|
|
|
// onCloseEnd callback
|
|
if (typeof this.options.onCloseEnd === 'function') {
|
|
this.options.onCloseEnd.call(this, this.el);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Place dropdown
|
|
*/
|
|
_placeDropdown() {
|
|
// Set width before calculating positionInfo
|
|
let idealWidth = this.options.constrainWidth
|
|
? this.el.getBoundingClientRect().width
|
|
: this.dropdownEl.getBoundingClientRect().width;
|
|
this.dropdownEl.style.width = idealWidth + 'px';
|
|
|
|
let positionInfo = this._getDropdownPosition();
|
|
this.dropdownEl.style.left = positionInfo.x + 'px';
|
|
this.dropdownEl.style.top = positionInfo.y + 'px';
|
|
this.dropdownEl.style.height = positionInfo.height + 'px';
|
|
this.dropdownEl.style.width = positionInfo.width + 'px';
|
|
this.dropdownEl.style.transformOrigin = `${
|
|
positionInfo.horizontalAlignment === 'left' ? '0' : '100%'
|
|
} ${positionInfo.verticalAlignment === 'top' ? '0' : '100%'}`;
|
|
}
|
|
|
|
/**
|
|
* Open Dropdown
|
|
*/
|
|
open() {
|
|
if (this.isOpen) {
|
|
return;
|
|
}
|
|
this.isOpen = true;
|
|
|
|
// onOpenStart callback
|
|
if (typeof this.options.onOpenStart === 'function') {
|
|
this.options.onOpenStart.call(this, this.el);
|
|
}
|
|
|
|
// Reset styles
|
|
this._resetDropdownStyles();
|
|
this.dropdownEl.style.display = 'block';
|
|
|
|
this._placeDropdown();
|
|
this._animateIn();
|
|
this._setupTemporaryEventHandlers();
|
|
}
|
|
|
|
/**
|
|
* Close Dropdown
|
|
*/
|
|
close() {
|
|
if (!this.isOpen) {
|
|
return;
|
|
}
|
|
this.isOpen = false;
|
|
this.focusedIndex = -1;
|
|
|
|
// onCloseStart callback
|
|
if (typeof this.options.onCloseStart === 'function') {
|
|
this.options.onCloseStart.call(this, this.el);
|
|
}
|
|
|
|
this._animateOut();
|
|
this._removeTemporaryEventHandlers();
|
|
|
|
if (this.options.autoFocus) {
|
|
this.el.focus();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recalculate dimensions
|
|
*/
|
|
recalculateDimensions() {
|
|
if (this.isOpen) {
|
|
this.$dropdownEl.css({
|
|
width: '',
|
|
height: '',
|
|
left: '',
|
|
top: '',
|
|
'transform-origin': ''
|
|
});
|
|
this._placeDropdown();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @static
|
|
* @memberof Dropdown
|
|
*/
|
|
Dropdown._dropdowns = [];
|
|
|
|
M.Dropdown = Dropdown;
|
|
|
|
if (M.jQueryLoaded) {
|
|
M.initializeJqueryWrapper(Dropdown, 'dropdown', 'M_Dropdown');
|
|
}
|
|
})(cash, M.anime);
|