comindware.ui
Version:
Comindware Core UI provides the basic components like editors, lists, dropdowns, popups that we so desperately need while creating Marionette-based single-page applications.
341 lines (296 loc) • 12.5 kB
JavaScript
/**
* Developer: Stepan Burguchev
* Date: 11/27/2014
* Copyright: 2009-2016 Comindware®
* All Rights Reserved
* Published under the MIT license
*/
import { $, Handlebars } from 'lib';
import { helpers } from 'utils';
import WindowService from '../../services/WindowService';
import template from '../templates/dropdown.hbs';
import BlurableBehavior from '../utils/BlurableBehavior';
import GlobalEventService from '../../services/GlobalEventService';
import ListenToElementMoveBehavior from '../utils/ListenToElementMoveBehavior';
import WrapperView from './WrapperView';
const classes = {
OPEN: 'open',
DROPDOWN_DOWN: 'dropdown__wrp_down',
DROPDOWN_WRP_OVER: 'dropdown__wrp_down-over',
DROPDOWN_UP: 'dropdown__wrp_up',
DROPDOWN_UP_OVER: 'dropdown__wrp_up-over'
};
const WINDOW_BORDER_OFFSET = 10;
const panelPosition = {
DOWN: 'down',
DOWN_OVER: 'down-over',
UP: 'up',
UP_OVER: 'up-over'
};
const panelMinWidth = {
NONE: 'none',
BUTTON_WIDTH: 'button-width'
};
const defaultOptions = {
autoOpen: true,
renderAfterClose: true,
panelPosition: panelPosition.DOWN,
panelMinWidth: panelMinWidth.BUTTON_WIDTH
};
/**
* @name DropdownView
* @memberof module:core.dropdown.views
* @class Composite View that implements dropdown logic similar to SELECT HTML-element.
* Unlike {@link module:core.dropdown.views.PopoutView PopoutView}, a panel doesn't have a speech bubble triangle and
* it's min-width is always determined and equal to the width of a button view.
*
* A dropdown view contains button and panel regions that can be fully customizable by the properties <code>buttonView</code> and <code>panelView</code>.
* <ul>
* <li>Button View is used for displaying a button. Click on that button trigger a panel to open.</li>
* <li>Panel View is used to display a panel that drops down.</li>
* </ul>
*
* Panel width is determined by its layout but it cannot be less than the button's width. Panel height is fully determined by its layout.
* A place where the panel appears depends on the <code>panelPosition</code> option.<br/>
* Possible events:<ul>
* <li><code>'open' (dropdownView)</code> - fires after the panel has opened.</li>
* <li><code>'close' (dropdownView, ...)</code> - fires after the panel has closed.
* If the panel was closed via <code>close(...)</code> method, the arguments of this method are transferred into this event.</li>
* <li><code>'button:\*' </code> - all events the buttonView triggers are repeated by this view with 'button:' prefix.</li>
* <li><code>'panel:\*' </code> - all events the panelView triggers are repeated by this view with 'panel:' prefix.</li>
* </ul>
* @constructor
* @extends Marionette.LayoutView
* @param {Object} options Options object.
* @param {Marionette.View} options.buttonView View class for displaying the button.
* @param {(Object|Function)} [options.buttonViewOptions] Options passed into the view on its creation.
* @param {Marionette.View} options.panelView View class for displaying the panel. The view is created every time the panel is triggered to open.
* @param {(Object|Function)} [options.panelViewOptions] Options passed into the view on its creation.
* @param {Boolean} [options.autoOpen=true] Whether click on the button should trigger the panel to open.
* @param {String} [options.panelPosition='down'] Opening direction:
* <ul><li><code>'down'</code> - opens down.</li>
* <li><code>'down-over'</code> - opens down and the panel is located above the button overlapping it.</li>
* <li><code>'up'</code> - opens up.</li>
* <li><code>'up-over'</code> - opens up and the panel is located above the button overlapping it.</li></ul>
* @param {Boolean} [options.renderAfterClose=true] Whether to trigger button render when the panel has closed.
* */
export default Marionette.LayoutView.extend(/** @lends module:core.dropdown.views.DropdownView.prototype */ {
initialize(options) {
_.extend(this.options, _.clone(defaultOptions), options || {});
helpers.ensureOption(options, 'buttonView');
helpers.ensureOption(options, 'panelView');
_.bindAll(this, 'open', 'close');
this.listenTo(WindowService, 'popup:close', this.__onWindowServicePopupClose);
},
template: Handlebars.compile(template),
className: 'dropdown',
regions: {
buttonRegion: '.js-button-region'
},
ui: {
button: '.js-button-region'
},
events: {
'click @ui.button': '__handleClick'
},
behaviors: {
BlurableBehavior: {
behaviorClass: BlurableBehavior,
onBlur: '__handleBlur'
},
ListenToElementMoveBehavior: {
behaviorClass: ListenToElementMoveBehavior
}
},
/**
* Contains an instance of <code>options.buttonView</code> if the dropdown is rendered, <code>null</code> otherwise.
* */
buttonView: null,
/**
* Contains an instance of <code>options.panelView</code> if the dropdown is open, <code>null</code> otherwise.
* The view is created every time (!) the panel is triggered to open.
* */
panelView: null,
onRender() {
if (this.button) {
this.stopListening(this.button);
}
this.button = new this.options.buttonView(_.extend({ parent: this }, _.result(this.options, 'buttonViewOptions')));
this.buttonView = this.button;
this.listenTo(this.button, 'all', (...args) => {
args[0] = `button:${args[0]}`;
this.triggerMethod(...args);
});
if (this.isShown) {
this.buttonRegion.show(this.button);
}
},
onShow() {
this.buttonRegion.show(this.button);
this.isShown = true;
},
onDestroy() {
if (this.isOpen) {
WindowService.closePopup(this.popupId);
}
},
__adjustPosition($panelEl) {
const viewportHeight = window.innerHeight;
const $buttonEl = this.buttonRegion.$el;
const buttonRect = $buttonEl.offset();
buttonRect.height = $buttonEl.outerHeight();
buttonRect.width = $buttonEl.outerWidth();
buttonRect.bottom = viewportHeight - buttonRect.top - buttonRect.height;
const panelRect = $panelEl.offset();
panelRect.height = $panelEl.outerHeight();
let position = this.options.panelPosition;
// switching position if there is not enough space
switch (position) {
case panelPosition.DOWN:
if (buttonRect.bottom < panelRect.height && buttonRect.top > buttonRect.bottom) {
position = panelPosition.UP;
}
break;
case panelPosition.DOWN_OVER:
if (buttonRect.bottom + buttonRect.height < panelRect.height && buttonRect.top > buttonRect.bottom) {
position = panelPosition.UP_OVER;
}
break;
case panelPosition.UP:
if (buttonRect.top < panelRect.height && buttonRect.bottom > buttonRect.top) {
position = panelPosition.UP;
}
break;
case panelPosition.UP_OVER:
if (buttonRect.top + buttonRect.height < panelRect.height && buttonRect.bottom > buttonRect.top) {
position = panelPosition.UP;
}
break;
default:
break;
}
// class adjustments
$panelEl.toggleClass(classes.DROPDOWN_DOWN, position === panelPosition.DOWN);
$panelEl.toggleClass(classes.DROPDOWN_WRP_OVER, position === panelPosition.DOWN_OVER);
$panelEl.toggleClass(classes.DROPDOWN_UP, position === panelPosition.UP);
$panelEl.toggleClass(classes.DROPDOWN_UP_OVER, position === panelPosition.UP_OVER);
// panel positioning
let top;
switch (position) {
case panelPosition.UP:
top = buttonRect.top - panelRect.height;
break;
case panelPosition.UP_OVER:
top = buttonRect.top + buttonRect.height - panelRect.height;
break;
case panelPosition.DOWN:
top = buttonRect.top + buttonRect.height;
break;
case panelPosition.DOWN_OVER:
top = buttonRect.top;
break;
default:
break;
}
// trying to fit into viewport
if (top + panelRect.height > viewportHeight - WINDOW_BORDER_OFFSET) {
top = viewportHeight - WINDOW_BORDER_OFFSET - panelRect.height;
}
if (top <= WINDOW_BORDER_OFFSET) {
top = WINDOW_BORDER_OFFSET;
}
const panelCss = {
top,
left: buttonRect.left
};
if (this.options.panelMinWidth === panelMinWidth.BUTTON_WIDTH) {
panelCss['min-width'] = buttonRect.width;
}
$panelEl.css(panelCss);
},
/**
* Opens the dropdown panel.
* */
open() {
if (this.isOpen) {
return;
}
this.trigger('before:open', this);
const panelViewOptions = _.extend(_.result(this.options, 'panelViewOptions') || {}, {
parent: this
});
this.$el.addClass(classes.OPEN);
this.panelView = new this.options.panelView(panelViewOptions);
this.panelView.on('all', (...args) => {
args[0] = `panel:${args[0]}`;
this.triggerMethod(...args);
});
const wrapperView = new WrapperView({
view: this.panelView,
className: 'dropdown__wrp'
});
this.popupId = WindowService.showTransientPopup(wrapperView, {
hostEl: this.el
});
this.__adjustPosition(wrapperView.$el);
this.listenToElementMoveOnce(this.el, this.close);
this.listenTo(GlobalEventService, 'window:mousedown:captured', this.__handleGlobalMousedown);
const activeElement = document.activeElement;
if (!this.__isNestedInButton(activeElement) && !this.__isNestedInPanel(activeElement)) {
this.panelView.$el.focus();
} else {
this.focus(activeElement);
}
this.__suppressHandlingBlur = false;
this.isOpen = true;
this.trigger('open', this);
},
/**
* Closes the dropdown panel.
* @param {...*} arguments Arguments transferred into the <code>'close'</code> event.
* */
close(...args) {
if (!this.isOpen || !$.contains(document.documentElement, this.el)) {
return;
}
this.trigger('before:close', this);
this.$el.removeClass(classes.OPEN);
WindowService.closePopup(this.popupId);
this.stopListeningToElementMove();
this.stopListening(GlobalEventService);
this.button.$el.focus();
this.isOpen = false;
this.trigger('close', this, ...args);
if (this.options.renderAfterClose) {
this.button.render();
}
},
__handleClick() {
if (this.options.autoOpen) {
this.open();
}
},
__isNestedInButton(testedEl) {
return this.el === testedEl || $.contains(this.el, testedEl);
},
__isNestedInPanel(testedEl) {
return WindowService.get(this.popupId).map(x => x.el).some(el => el === testedEl || $.contains(el, testedEl));
},
__handleBlur() {
if (!this.__suppressHandlingBlur && !this.__isNestedInButton(document.activeElement) && !this.__isNestedInPanel(document.activeElement)) {
this.close();
}
},
__handleGlobalMousedown(target) {
if (this.__isNestedInPanel(target)) {
this.__suppressHandlingBlur = true;
} else if (!this.__isNestedInButton(target)) {
this.close();
}
},
__onWindowServicePopupClose(popupId) {
if (this.isOpen && this.popupId === popupId) {
this.close();
}
}
});