'use strict'; import $ from 'jquery'; import { Keyboard } from './foundation.util.keyboard'; import { GetYoDigits, ignoreMousedisappear } from './foundation.core.utils'; import { Positionable } from './foundation.positionable'; import { Triggers } from './foundation.util.triggers'; /** * Dropdown module. * @module foundation.dropdown * @requires foundation.util.keyboard * @requires foundation.util.box * @requires foundation.util.triggers */ class Dropdown extends Positionable { /** * Creates a new instance of a dropdown. * @class * @name Dropdown * @param {jQuery} element - jQuery object to make into a dropdown. * Object should be of the dropdown panel, rather than its anchor. * @param {Object} options - Overrides to the default plugin settings. */ _setup(element, options) { this.$element = element; this.options = $.extend({}, Dropdown.defaults, this.$element.data(), options); this.className = 'Dropdown'; // ie9 back compat // Triggers init is idempotent, just need to make sure it is initialized Triggers.init($); this._init(); Keyboard.register('Dropdown', { 'ENTER': 'toggle', 'SPACE': 'toggle', 'ESCAPE': 'close' }); } /** * Initializes the plugin by setting/checking options and attributes, adding helper variables, and saving the anchor. * @function * @private */ _init() { var $id = this.$element.attr('id'); this.$anchors = $(`[data-toggle="${$id}"]`).length ? $(`[data-toggle="${$id}"]`) : $(`[data-open="${$id}"]`); this.$anchors.attr({ 'aria-controls': $id, 'data-is-focus': false, 'data-yeti-box': $id, 'aria-haspopup': true, 'aria-expanded': false }); this._setCurrentAnchor(this.$anchors.first()); if(this.options.parentClass){ this.$parent = this.$element.parents('.' + this.options.parentClass); }else{ this.$parent = null; } // Set [aria-labelledby] on the Dropdown if it is not set if (typeof this.$element.attr('aria-labelledby') === 'undefined') { // Get the anchor ID or create one if (typeof this.$currentAnchor.attr('id') === 'undefined') { this.$currentAnchor.attr('id', GetYoDigits(6, 'dd-anchor')); }; this.$element.attr('aria-labelledby', this.$currentAnchor.attr('id')); } this.$element.attr({ 'aria-hidden': 'true', 'data-yeti-box': $id, 'data-resize': $id, }); super._init(); this._events(); } _getDefaultPosition() { // handle legacy classnames var position = this.$element[0].className.match(/(top|left|right|bottom)/g); if(position) { return position[0]; } else { return 'bottom' } } _getDefaultAlignment() { // handle legacy float approach var horizontalPosition = /float-(\S+)/.exec(this.$currentAnchor.attr('class')); if(horizontalPosition) { return horizontalPosition[1]; } return super._getDefaultAlignment(); } /** * Sets the position and orientation of the dropdown pane, checks for collisions if allow-overlap is not true. * Recursively calls itself if a collision is detected, with a new position class. * @function * @private */ _setPosition() { this.$element.removeClass(`has-position-${this.position} has-alignment-${this.alignment}`); super._setPosition(this.$currentAnchor, this.$element, this.$parent); this.$element.addClass(`has-position-${this.position} has-alignment-${this.alignment}`); } /** * Make it a current anchor. * Current anchor as the reference for the position of Dropdown panes. * @param {HTML} el - DOM element of the anchor. * @function * @private */ _setCurrentAnchor(el) { this.$currentAnchor = $(el); } /** * Adds event listeners to the element utilizing the triggers utility library. * @function * @private */ _events() { var _this = this; this.$element.on({ 'open.zf.trigger': this.open.bind(this), 'close.zf.trigger': this.close.bind(this), 'toggle.zf.trigger': this.toggle.bind(this), 'resizeme.zf.trigger': this._setPosition.bind(this) }); this.$anchors.off('click.zf.trigger') .on('click.zf.trigger', function() { _this._setCurrentAnchor(this); }); if(this.options.hover){ this.$anchors.off('mouseenter.zf.dropdown mouseleave.zf.dropdown') .on('mouseenter.zf.dropdown', function(){ _this._setCurrentAnchor(this); var bodyData = $('body').data(); if(typeof(bodyData.whatinput) === 'undefined' || bodyData.whatinput === 'mouse') { clearTimeout(_this.timeout); _this.timeout = setTimeout(function(){ _this.open(); _this.$anchors.data('hover', true); }, _this.options.hoverDelay); } }).on('mouseleave.zf.dropdown', ignoreMousedisappear(function(){ clearTimeout(_this.timeout); _this.timeout = setTimeout(function(){ _this.close(); _this.$anchors.data('hover', false); }, _this.options.hoverDelay); })); if(this.options.hoverPane){ this.$element.off('mouseenter.zf.dropdown mouseleave.zf.dropdown') .on('mouseenter.zf.dropdown', function(){ clearTimeout(_this.timeout); }).on('mouseleave.zf.dropdown', ignoreMousedisappear(function(){ clearTimeout(_this.timeout); _this.timeout = setTimeout(function(){ _this.close(); _this.$anchors.data('hover', false); }, _this.options.hoverDelay); })); } } this.$anchors.add(this.$element).on('keydown.zf.dropdown', function(e) { var $target = $(this), visibleFocusableElements = Keyboard.findFocusable(_this.$element); Keyboard.handleKey(e, 'Dropdown', { open: function() { if ($target.is(_this.$anchors) && !$target.is('input, textarea')) { _this.open(); _this.$element.attr('tabindex', -1).focus(); e.preventDefault(); } }, close: function() { _this.close(); _this.$anchors.focus(); } }); }); } /** * Adds an event handler to the body to close any dropdowns on a click. * @function * @private */ _addBodyHandler() { var $body = $(document.body).not(this.$element), _this = this; $body.off('click.zf.dropdown') .on('click.zf.dropdown', function(e){ if(_this.$anchors.is(e.target) || _this.$anchors.find(e.target).length) { return; } if(_this.$element.is(e.target) || _this.$element.find(e.target).length) { return; } _this.close(); $body.off('click.zf.dropdown'); }); } /** * Opens the dropdown pane, and fires a bubbling event to close other dropdowns. * @function * @fires Dropdown#closeme * @fires Dropdown#show */ open() { // var _this = this; /** * Fires to close other open dropdowns, typically when dropdown is opening * @event Dropdown#closeme */ this.$element.trigger('closeme.zf.dropdown', this.$element.attr('id')); this.$anchors.addClass('hover') .attr({'aria-expanded': true}); // this.$element/*.show()*/; this.$element.addClass('is-opening'); this._setPosition(); this.$element.removeClass('is-opening').addClass('is-open') .attr({'aria-hidden': false}); if(this.options.autoFocus){ var $focusable = Keyboard.findFocusable(this.$element); if($focusable.length){ $focusable.eq(0).focus(); } } if(this.options.closeOnClick){ this._addBodyHandler(); } if (this.options.trapFocus) { Keyboard.trapFocus(this.$element); } /** * Fires once the dropdown is visible. * @event Dropdown#show */ this.$element.trigger('show.zf.dropdown', [this.$element]); } /** * Closes the open dropdown pane. * @function * @fires Dropdown#hide */ close() { if(!this.$element.hasClass('is-open')){ return false; } this.$element.removeClass('is-open') .attr({'aria-hidden': true}); this.$anchors.removeClass('hover') .attr('aria-expanded', false); /** * Fires once the dropdown is no longer visible. * @event Dropdown#hide */ this.$element.trigger('hide.zf.dropdown', [this.$element]); if (this.options.trapFocus) { Keyboard.releaseFocus(this.$element); } } /** * Toggles the dropdown pane's visibility. * @function */ toggle() { if(this.$element.hasClass('is-open')){ if(this.$anchors.data('hover')) return; this.close(); }else{ this.open(); } } /** * Destroys the dropdown. * @function */ _destroy() { this.$element.off('.zf.trigger').hide(); this.$anchors.off('.zf.dropdown'); $(document.body).off('click.zf.dropdown'); } } Dropdown.defaults = { /** * Class that designates bounding container of Dropdown (default: window) * @option * @type {?string} * @default null */ parentClass: null, /** * Amount of time to delay opening a submenu on hover event. * @option * @type {number} * @default 250 */ hoverDelay: 250, /** * Allow submenus to open on hover events * @option * @type {boolean} * @default false */ hover: false, /** * Don't close dropdown when hovering over dropdown pane * @option * @type {boolean} * @default false */ hoverPane: false, /** * Number of pixels between the dropdown pane and the triggering element on open. * @option * @type {number} * @default 0 */ vOffset: 0, /** * Number of pixels between the dropdown pane and the triggering element on open. * @option * @type {number} * @default 0 */ hOffset: 0, /** * Position of dropdown. Can be left, right, bottom, top, or auto. * @option * @type {string} * @default 'auto' */ position: 'auto', /** * Alignment of dropdown relative to anchor. Can be left, right, bottom, top, center, or auto. * @option * @type {string} * @default 'auto' */ alignment: 'auto', /** * Allow overlap of container/window. If false, dropdown will first try to position as defined by data-position and data-alignment, but reposition if it would cause an overflow. * @option * @type {boolean} * @default false */ allowOverlap: false, /** * Allow overlap of only the bottom of the container. This is the most common * behavior for dropdowns, allowing the dropdown to extend the bottom of the * screen but not otherwise influence or break out of the container. * @option * @type {boolean} * @default true */ allowBottomOverlap: true, /** * Allow the plugin to trap focus to the dropdown pane if opened with keyboard commands. * @option * @type {boolean} * @default false */ trapFocus: false, /** * Allow the plugin to set focus to the first focusable element within the pane, regardless of method of opening. * @option * @type {boolean} * @default false */ autoFocus: false, /** * Allows a click on the body to close the dropdown. * @option * @type {boolean} * @default false */ closeOnClick: false }; export {Dropdown};