foundation.dropdown.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. 'use strict';
  2. import $ from 'jquery';
  3. import { Keyboard } from './foundation.util.keyboard';
  4. import { GetYoDigits, ignoreMousedisappear } from './foundation.core.utils';
  5. import { Positionable } from './foundation.positionable';
  6. import { Triggers } from './foundation.util.triggers';
  7. import { Touch } from './foundation.util.touch'
  8. /**
  9. * Dropdown module.
  10. * @module foundation.dropdown
  11. * @requires foundation.util.keyboard
  12. * @requires foundation.util.box
  13. * @requires foundation.util.triggers
  14. */
  15. class Dropdown extends Positionable {
  16. /**
  17. * Creates a new instance of a dropdown.
  18. * @class
  19. * @name Dropdown
  20. * @param {jQuery} element - jQuery object to make into a dropdown.
  21. * Object should be of the dropdown panel, rather than its anchor.
  22. * @param {Object} options - Overrides to the default plugin settings.
  23. */
  24. _setup(element, options) {
  25. this.$element = element;
  26. this.options = $.extend({}, Dropdown.defaults, this.$element.data(), options);
  27. this.className = 'Dropdown'; // ie9 back compat
  28. // Triggers init is idempotent, just need to make sure it is initialized
  29. Triggers.init($);
  30. this._init();
  31. Keyboard.register('Dropdown', {
  32. 'ENTER': 'toggle',
  33. 'SPACE': 'toggle',
  34. 'ESCAPE': 'close'
  35. });
  36. }
  37. /**
  38. * Initializes the plugin by setting/checking options and attributes, adding helper variables, and saving the anchor.
  39. * @function
  40. * @private
  41. */
  42. _init() {
  43. var $id = this.$element.attr('id');
  44. this.$anchors = $(`[data-toggle="${$id}"]`).length ? $(`[data-toggle="${$id}"]`) : $(`[data-open="${$id}"]`);
  45. this.$anchors.attr({
  46. 'aria-controls': $id,
  47. 'data-is-focus': false,
  48. 'data-yeti-box': $id,
  49. 'aria-haspopup': true,
  50. 'aria-expanded': false
  51. });
  52. this._setCurrentAnchor(this.$anchors.first());
  53. if(this.options.parentClass){
  54. this.$parent = this.$element.parents('.' + this.options.parentClass);
  55. }else{
  56. this.$parent = null;
  57. }
  58. // Set [aria-labelledby] on the Dropdown if it is not set
  59. if (typeof this.$element.attr('aria-labelledby') === 'undefined') {
  60. // Get the anchor ID or create one
  61. if (typeof this.$currentAnchor.attr('id') === 'undefined') {
  62. this.$currentAnchor.attr('id', GetYoDigits(6, 'dd-anchor'));
  63. };
  64. this.$element.attr('aria-labelledby', this.$currentAnchor.attr('id'));
  65. }
  66. this.$element.attr({
  67. 'aria-hidden': 'true',
  68. 'data-yeti-box': $id,
  69. 'data-resize': $id,
  70. });
  71. super._init();
  72. this._events();
  73. }
  74. _getDefaultPosition() {
  75. // handle legacy classnames
  76. var position = this.$element[0].className.match(/(top|left|right|bottom)/g);
  77. if(position) {
  78. return position[0];
  79. } else {
  80. return 'bottom'
  81. }
  82. }
  83. _getDefaultAlignment() {
  84. // handle legacy float approach
  85. var horizontalPosition = /float-(\S+)/.exec(this.$currentAnchor.attr('class'));
  86. if(horizontalPosition) {
  87. return horizontalPosition[1];
  88. }
  89. return super._getDefaultAlignment();
  90. }
  91. /**
  92. * Sets the position and orientation of the dropdown pane, checks for collisions if allow-overlap is not true.
  93. * Recursively calls itself if a collision is detected, with a new position class.
  94. * @function
  95. * @private
  96. */
  97. _setPosition() {
  98. this.$element.removeClass(`has-position-${this.position} has-alignment-${this.alignment}`);
  99. super._setPosition(this.$currentAnchor, this.$element, this.$parent);
  100. this.$element.addClass(`has-position-${this.position} has-alignment-${this.alignment}`);
  101. }
  102. /**
  103. * Make it a current anchor.
  104. * Current anchor as the reference for the position of Dropdown panes.
  105. * @param {HTML} el - DOM element of the anchor.
  106. * @function
  107. * @private
  108. */
  109. _setCurrentAnchor(el) {
  110. this.$currentAnchor = $(el);
  111. }
  112. /**
  113. * Adds event listeners to the element utilizing the triggers utility library.
  114. * @function
  115. * @private
  116. */
  117. _events() {
  118. var _this = this;
  119. this.$element.on({
  120. 'open.zf.trigger': this.open.bind(this),
  121. 'close.zf.trigger': this.close.bind(this),
  122. 'toggle.zf.trigger': this.toggle.bind(this),
  123. 'resizeme.zf.trigger': this._setPosition.bind(this)
  124. });
  125. this.$anchors.off('click.zf.trigger')
  126. .on('click.zf.trigger', function() { _this._setCurrentAnchor(this); });
  127. if(this.options.hover){
  128. this.$anchors.off('mouseenter.zf.dropdown mouseleave.zf.dropdown')
  129. .on('mouseenter.zf.dropdown', function(){
  130. _this._setCurrentAnchor(this);
  131. var bodyData = $('body').data();
  132. if(typeof(bodyData.whatinput) === 'undefined' || bodyData.whatinput === 'mouse') {
  133. clearTimeout(_this.timeout);
  134. _this.timeout = setTimeout(function(){
  135. _this.open();
  136. _this.$anchors.data('hover', true);
  137. }, _this.options.hoverDelay);
  138. }
  139. }).on('mouseleave.zf.dropdown', ignoreMousedisappear(function(){
  140. clearTimeout(_this.timeout);
  141. _this.timeout = setTimeout(function(){
  142. _this.close();
  143. _this.$anchors.data('hover', false);
  144. }, _this.options.hoverDelay);
  145. }));
  146. if(this.options.hoverPane){
  147. this.$element.off('mouseenter.zf.dropdown mouseleave.zf.dropdown')
  148. .on('mouseenter.zf.dropdown', function(){
  149. clearTimeout(_this.timeout);
  150. }).on('mouseleave.zf.dropdown', ignoreMousedisappear(function(){
  151. clearTimeout(_this.timeout);
  152. _this.timeout = setTimeout(function(){
  153. _this.close();
  154. _this.$anchors.data('hover', false);
  155. }, _this.options.hoverDelay);
  156. }));
  157. }
  158. }
  159. this.$anchors.add(this.$element).on('keydown.zf.dropdown', function(e) {
  160. var $target = $(this),
  161. visibleFocusableElements = Keyboard.findFocusable(_this.$element);
  162. Keyboard.handleKey(e, 'Dropdown', {
  163. open: function() {
  164. if ($target.is(_this.$anchors) && !$target.is('input, textarea')) {
  165. _this.open();
  166. _this.$element.attr('tabindex', -1).focus();
  167. e.preventDefault();
  168. }
  169. },
  170. close: function() {
  171. _this.close();
  172. _this.$anchors.focus();
  173. }
  174. });
  175. });
  176. }
  177. /**
  178. * Adds an event handler to the body to close any dropdowns on a click.
  179. * @function
  180. * @private
  181. */
  182. _addBodyHandler() {
  183. var $body = $(document.body).not(this.$element),
  184. _this = this;
  185. $body.off('click.zf.dropdown')
  186. .on('click.zf.dropdown', function(e){
  187. if(_this.$anchors.is(e.target) || _this.$anchors.find(e.target).length) {
  188. return;
  189. }
  190. if(_this.$element.is(e.target) || _this.$element.find(e.target).length) {
  191. return;
  192. }
  193. _this.close();
  194. $body.off('click.zf.dropdown');
  195. });
  196. }
  197. /**
  198. * Opens the dropdown pane, and fires a bubbling event to close other dropdowns.
  199. * @function
  200. * @fires Dropdown#closeme
  201. * @fires Dropdown#show
  202. */
  203. open() {
  204. // var _this = this;
  205. /**
  206. * Fires to close other open dropdowns, typically when dropdown is opening
  207. * @event Dropdown#closeme
  208. */
  209. this.$element.trigger('closeme.zf.dropdown', this.$element.attr('id'));
  210. this.$anchors.addClass('hover')
  211. .attr({'aria-expanded': true});
  212. // this.$element/*.show()*/;
  213. this.$element.addClass('is-opening');
  214. this._setPosition();
  215. this.$element.removeClass('is-opening').addClass('is-open')
  216. .attr({'aria-hidden': false});
  217. if(this.options.autoFocus){
  218. var $focusable = Keyboard.findFocusable(this.$element);
  219. if($focusable.length){
  220. $focusable.eq(0).focus();
  221. }
  222. }
  223. if(this.options.closeOnClick){ this._addBodyHandler(); }
  224. if (this.options.trapFocus) {
  225. Keyboard.trapFocus(this.$element);
  226. }
  227. /**
  228. * Fires once the dropdown is visible.
  229. * @event Dropdown#show
  230. */
  231. this.$element.trigger('show.zf.dropdown', [this.$element]);
  232. }
  233. /**
  234. * Closes the open dropdown pane.
  235. * @function
  236. * @fires Dropdown#hide
  237. */
  238. close() {
  239. if(!this.$element.hasClass('is-open')){
  240. return false;
  241. }
  242. this.$element.removeClass('is-open')
  243. .attr({'aria-hidden': true});
  244. this.$anchors.removeClass('hover')
  245. .attr('aria-expanded', false);
  246. /**
  247. * Fires once the dropdown is no longer visible.
  248. * @event Dropdown#hide
  249. */
  250. this.$element.trigger('hide.zf.dropdown', [this.$element]);
  251. if (this.options.trapFocus) {
  252. Keyboard.releaseFocus(this.$element);
  253. }
  254. }
  255. /**
  256. * Toggles the dropdown pane's visibility.
  257. * @function
  258. */
  259. toggle() {
  260. if(this.$element.hasClass('is-open')){
  261. if(this.$anchors.data('hover')) return;
  262. this.close();
  263. }else{
  264. this.open();
  265. }
  266. }
  267. /**
  268. * Destroys the dropdown.
  269. * @function
  270. */
  271. _destroy() {
  272. this.$element.off('.zf.trigger').hide();
  273. this.$anchors.off('.zf.dropdown');
  274. $(document.body).off('click.zf.dropdown');
  275. }
  276. }
  277. Dropdown.defaults = {
  278. /**
  279. * Class that designates bounding container of Dropdown (default: window)
  280. * @option
  281. * @type {?string}
  282. * @default null
  283. */
  284. parentClass: null,
  285. /**
  286. * Amount of time to delay opening a submenu on hover event.
  287. * @option
  288. * @type {number}
  289. * @default 250
  290. */
  291. hoverDelay: 250,
  292. /**
  293. * Allow submenus to open on hover events
  294. * @option
  295. * @type {boolean}
  296. * @default false
  297. */
  298. hover: false,
  299. /**
  300. * Don't close dropdown when hovering over dropdown pane
  301. * @option
  302. * @type {boolean}
  303. * @default false
  304. */
  305. hoverPane: false,
  306. /**
  307. * Number of pixels between the dropdown pane and the triggering element on open.
  308. * @option
  309. * @type {number}
  310. * @default 0
  311. */
  312. vOffset: 0,
  313. /**
  314. * Number of pixels between the dropdown pane and the triggering element on open.
  315. * @option
  316. * @type {number}
  317. * @default 0
  318. */
  319. hOffset: 0,
  320. /**
  321. * Position of dropdown. Can be left, right, bottom, top, or auto.
  322. * @option
  323. * @type {string}
  324. * @default 'auto'
  325. */
  326. position: 'auto',
  327. /**
  328. * Alignment of dropdown relative to anchor. Can be left, right, bottom, top, center, or auto.
  329. * @option
  330. * @type {string}
  331. * @default 'auto'
  332. */
  333. alignment: 'auto',
  334. /**
  335. * 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.
  336. * @option
  337. * @type {boolean}
  338. * @default false
  339. */
  340. allowOverlap: false,
  341. /**
  342. * Allow overlap of only the bottom of the container. This is the most common
  343. * behavior for dropdowns, allowing the dropdown to extend the bottom of the
  344. * screen but not otherwise influence or break out of the container.
  345. * @option
  346. * @type {boolean}
  347. * @default true
  348. */
  349. allowBottomOverlap: true,
  350. /**
  351. * Allow the plugin to trap focus to the dropdown pane if opened with keyboard commands.
  352. * @option
  353. * @type {boolean}
  354. * @default false
  355. */
  356. trapFocus: false,
  357. /**
  358. * Allow the plugin to set focus to the first focusable element within the pane, regardless of method of opening.
  359. * @option
  360. * @type {boolean}
  361. * @default false
  362. */
  363. autoFocus: false,
  364. /**
  365. * Allows a click on the body to close the dropdown.
  366. * @option
  367. * @type {boolean}
  368. * @default false
  369. */
  370. closeOnClick: false
  371. };
  372. export {Dropdown};