slickgrid
Version:
A lightning fast JavaScript grid/spreadsheet
705 lines (619 loc) • 28.5 kB
JavaScript
(function ($) {
// register namespace
$.extend(true, window, {
"Slick": {
"Plugins": {
"ContextMenu": ContextMenu
}
}
});
/***
* A plugin to add Context Menu (mouse right+click), it subscribes to the cell "onContextMenu" event.
* The "contextMenu" is defined in the Grid Options object
* You can use it to change a data property (only 1) through a list of Options AND/OR through a list of Commands.
* A good example of a Command would be an Export to CSV, that can be run from anywhere in the grid by doing a mouse right+click
*
* Note:
* There is only 1 list of Options, so typically that would be use for 1 column
* if you plan to use different Options for different columns, then the CellMenu plugin might be better suited.
*
* USAGE:
*
* Add the slick.contextmenu.(js|css) files and register it with the grid.
*
* To specify a menu in a column header, extend the column definition like so:
* var contextMenuPlugin = new Slick.Plugins.ContextMenu(columns, grid, options);
*
* Available grid options, by defining a contextMenu object:
*
* var options = {
* enableCellNavigation: true,
* contextMenu: {
* optionTitle: 'Change Priority',
* optionShownOverColumnIds: ["priority"],
* optionItems: [
* { option: 0, title: 'none', cssClass: 'italic' },
* { divider: true },
* "divider" // just the string is also accepted
* { option: 1, iconCssClass: 'fa fa-fire grey', title: 'Low' },
* { option: 3, iconCssClass: 'fa fa-fire red', title: 'High' },
* { option: 2, iconCssClass: 'fa fa-fire orange', title: 'Medium' },
* { option: 4, iconCssClass: 'fa fa-fire', title: 'Extreme', disabled: true },
* ],
* commandTitle: 'Commands',
* commandShownOverColumnIds: ["title", "complete", "start", "finish", "effortDriven"],
* commandItems: [
* { command: 'export-excel', title: 'Export to CSV', iconCssClass: 'fa fa-file-excel-o', cssClass: '' },
* { command: 'delete-row', title: 'Delete Row', cssClass: 'bold', textCssClass: 'red' },
* { command: 'help', title: 'Help', iconCssClass: 'fa fa-question-circle',},
* { divider: true },
* ],
* }
* };
*
*
* Available contextMenu properties:
* commandTitle: Title of the Command section (optional)
* commandItems: Array of Command item objects (command/title pair)
* commandShownOverColumnIds: Define which column to show the Commands list. If not defined (defaults), the menu will be shown over all columns
* optionTitle: Title of the Option section (optional)
* optionItems: Array of Options item objects (option/title pair)
* optionShownOverColumnIds: Define which column to show the Options list. If not defined (defaults), the menu will be shown over all columns
* hideCloseButton: Hide the Close button on top right (defaults to false)
* hideCommandSection: Hide the Commands section even when the commandItems array is filled (defaults to false)
* hideMenuOnScroll: Do we want to hide the Cell Menu when a scrolling event occurs (defaults to false)?
* hideOptionSection: Hide the Options section even when the optionItems array is filled (defaults to false)
* maxHeight: Maximum height that the drop menu will have, can be a number (250) or text ("none")
* width: Width that the drop menu will have, can be a number (250) or text (defaults to "auto")
* autoAdjustDrop: Auto-align dropup or dropdown menu to the left or right depending on grid viewport available space (defaults to true)
* autoAdjustDropOffset: Optionally add an offset to the auto-align of the drop menu (defaults to -4)
* autoAlignSide: Auto-align drop menu to the left or right depending on grid viewport available space (defaults to true)
* autoAlignSideOffset: Optionally add an offset to the left/right side auto-align (defaults to 0)
* menuUsabilityOverride: Callback method that user can override the default behavior of enabling/disabling the menu from being usable (must be combined with a custom formatter)
*
*
* Available menu Command/Option item properties:
* action: Optionally define a callback function that gets executed when item is chosen (and/or use the onCommand event)
* command: A command identifier to be passed to the onCommand event handlers (when using "commandItems").
* option: An option to be passed to the onOptionSelected event handlers (when using "optionItems").
* title: Menu item text.
* divider: Boolean which tell if the current item is a divider, not an actual command. You could also pass "divider" instead of an object
* disabled: Whether the item/command is disabled.
* hidden: Whether the item/command is hidden.
* tooltip: Item tooltip.
* cssClass: A CSS class to be added to the menu item container.
* iconCssClass: A CSS class to be added to the menu item icon.
* textCssClass: A CSS class to be added to the menu item text.
* iconImage: A url to the icon image.
* itemVisibilityOverride: Callback method that user can override the default behavior of showing/hiding an item from the list
* itemUsabilityOverride: Callback method that user can override the default behavior of enabling/disabling an item from the list
*
* The plugin exposes the following events:
*
* onAfterMenuShow: Fired after the menu is shown. You can customize the menu or dismiss it by returning false.
* Event args:
* cell: Cell or column index
* row: Row index
* grid: Reference to the grid.
*
* onBeforeMenuShow: Fired before the menu is shown. You can customize the menu or dismiss it by returning false.
* Event args:
* cell: Cell or column index
* row: Row index
* grid: Reference to the grid.
*
* onBeforeMenuClose: Fired when the menu is closing.
* Event args:
* cell: Cell or column index
* row: Row index
* grid: Reference to the grid.
* menu: Menu DOM element
*
* onCommand: Fired on menu option clicked from the Command items list
* Event args:
* cell: Cell or column index
* row: Row index
* grid: Reference to the grid.
* command: Menu command identified.
* item: Menu item selected
* column: Cell Column definition
* dataContext: Cell Data Context (data object)
* value: Value of the cell we triggered the context menu from
*
* onOptionSelected: Fired on menu option clicked from the Option items list
* Event args:
* cell: Cell or column index
* row: Row index
* grid: Reference to the grid.
* option: Menu option selected.
* item: Menu item selected
* column: Cell Column definition
* dataContext: Cell Data Context (data object)
*
*
* @param options {Object} Context Menu Options
* @class Slick.Plugins.ContextMenu
* @constructor
*/
function ContextMenu(optionProperties) {
var _contextMenuProperties;
var _currentCell = -1;
var _currentRow = -1;
var _grid;
var _gridOptions;
var _gridUid = "";
var _handler = new Slick.EventHandler();
var _self = this;
var $optionTitleElm;
var $commandTitleElm;
var $menu;
var _defaults = {
autoAdjustDrop: true, // dropup/dropdown
autoAlignSide: true, // left/right
autoAdjustDropOffset: -4,
autoAlignSideOffset: 0,
hideMenuOnScroll: false,
maxHeight: "none",
width: "auto",
optionShownOverColumnIds: [],
commandShownOverColumnIds: [],
};
function init(grid) {
_grid = grid;
_gridOptions = grid.getOptions();
_contextMenuProperties = $.extend({}, _defaults, optionProperties);
_gridUid = (grid && grid.getUID) ? grid.getUID() : "";
_handler.subscribe(_grid.onContextMenu, handleOnContextMenu);
if (_contextMenuProperties.hideMenuOnScroll) {
_handler.subscribe(_grid.onScroll, destroyMenu);
}
}
function setOptions(newOptions) {
_contextMenuProperties = $.extend({}, _contextMenuProperties, newOptions);
// on the array properties, we want to make sure to overwrite them and not just extending them
if (newOptions.commandShownOverColumnIds) {
_contextMenuProperties.commandShownOverColumnIds = newOptions.commandShownOverColumnIds;
}
if (newOptions.optionShownOverColumnIds) {
_contextMenuProperties.optionShownOverColumnIds = newOptions.optionShownOverColumnIds;
}
}
function destroy() {
_self.onAfterMenuShow.unsubscribe();
_self.onBeforeMenuShow.unsubscribe();
_self.onBeforeMenuClose.unsubscribe();
_self.onCommand.unsubscribe();
_self.onOptionSelected.unsubscribe();
_handler.unsubscribeAll();
if ($menu && $menu.remove) {
$menu.remove();
}
$commandTitleElm = null;
$optionTitleElm = null;
$menu = null;
}
function createMenu(e) {
var cell = _grid.getCellFromEvent(e);
_currentCell = cell && cell.cell;
_currentRow = cell && cell.row;
var columnDef = _grid.getColumns()[_currentCell];
var dataContext = _grid.getDataItem(_currentRow);
var isColumnOptionAllowed = checkIsColumnAllowed(_contextMenuProperties.optionShownOverColumnIds, columnDef.id);
var isColumnCommandAllowed = checkIsColumnAllowed(_contextMenuProperties.commandShownOverColumnIds, columnDef.id);
var commandItems = _contextMenuProperties.commandItems || [];
var optionItems = _contextMenuProperties.optionItems || [];
// make sure there's at least something to show before creating the Context Menu
if (!columnDef || (!isColumnCommandAllowed && !isColumnOptionAllowed) || (!commandItems.length && !optionItems.length)) {
return;
}
// delete any prior context menu
destroyMenu(e);
// Let the user modify the menu or cancel altogether,
// or provide alternative menu implementation.
if (_self.onBeforeMenuShow.notify({
"cell": _currentCell,
"row": _currentRow,
"grid": _grid
}, e, _self) == false) {
return;
}
// create a new context menu
var maxHeight = isNaN(_contextMenuProperties.maxHeight) ? _contextMenuProperties.maxHeight : _contextMenuProperties.maxHeight + "px";
var width = isNaN(_contextMenuProperties.width) ? _contextMenuProperties.width : _contextMenuProperties.width + "px";
var menuStyle = "width: " + width + "; max-height: " + maxHeight;
var menu = $('<div class="slick-context-menu ' + _gridUid + '" style="' + menuStyle + '" />')
.css("top", e.pageY)
.css("left", e.pageX)
.css("display", "none");
var closeButtonHtml = '<button type="button" class="close" data-dismiss="slick-context-menu" aria-label="Close">'
+ '<span class="close" aria-hidden="true">×</span></button>';
// -- Option List section
if (!_contextMenuProperties.hideOptionSection && isColumnOptionAllowed && optionItems.length > 0) {
var $optionMenu = $('<div class="slick-context-menu-option-list" />');
if (!_contextMenuProperties.hideCloseButton) {
$(closeButtonHtml).on("click", handleCloseButtonClicked).appendTo(menu);
}
$optionMenu.appendTo(menu);
populateOptionItems(
_contextMenuProperties,
$optionMenu,
optionItems,
{ cell: _currentCell, row: _currentRow, column: columnDef, dataContext: dataContext, grid: _grid }
);
}
// -- Command List section
if (!_contextMenuProperties.hideCommandSection && isColumnCommandAllowed && commandItems.length > 0) {
var $commandMenu = $('<div class="slick-context-menu-command-list" />');
if (!_contextMenuProperties.hideCloseButton && (!isColumnOptionAllowed || optionItems.length === 0 || _contextMenuProperties.hideOptionSection)) {
$(closeButtonHtml).on("click", handleCloseButtonClicked).appendTo(menu);
}
$commandMenu.appendTo(menu);
populateCommandItems(
_contextMenuProperties,
$commandMenu,
commandItems,
{ cell: _currentCell, row: _currentRow, column: columnDef, dataContext: dataContext, grid: _grid }
);
}
menu.show();
menu.appendTo("body");
if (_self.onAfterMenuShow.notify({
"cell": _currentCell,
"row": _currentRow,
"grid": _grid
}, e, _self) == false) {
return;
}
return menu;
}
function handleCloseButtonClicked(e) {
if(!e.isDefaultPrevented()) {
destroyMenu(e);
}
}
function destroyMenu(e, args) {
$menu = $menu || $(".slick-context-menu." + _gridUid);
if ($menu && $menu.length > 0) {
if (_self.onBeforeMenuClose.notify({
"cell": args && args.cell,
"row": args && args.row,
"grid": _grid,
"menu": $menu
}, e, _self) == false) {
return;
}
if ($menu && $menu.remove) {
$menu.remove();
$menu = null;
}
}
}
function checkIsColumnAllowed(columnIds, columnId) {
var isAllowedColumn = false;
if (columnIds && columnIds.length > 0) {
for (var o = 0, ln = columnIds.length; o < ln; o++) {
if (columnIds[o] === columnId) {
isAllowedColumn = true;
}
}
} else {
isAllowedColumn = true;
}
return isAllowedColumn;
}
function calculateAvailableSpaceBottom(element) {
var windowHeight = $(window).innerHeight() || 0;
var pageScroll = $(window).scrollTop() || 0;
if (element && element.offset && element.length > 0) {
var elementOffsetTop = element.offset().top;
return windowHeight - (elementOffsetTop - pageScroll);
}
return 0;
}
function calculateAvailableSpaceTop(element) {
var pageScroll = $(window).scrollTop() || 0;
if (element && element.offset && element.length > 0) {
var elementOffsetTop = element.offset().top;
return elementOffsetTop - pageScroll;
}
return 0;
}
function handleOnContextMenu(e, args) {
e.preventDefault();
var cell = _grid.getCellFromEvent(e);
var columnDef = _grid.getColumns()[cell.cell];
var dataContext = _grid.getDataItem(cell.row);
// run the override function (when defined), if the result is false it won't go further
if (!args) {
args = {};
}
args.cell = cell.cell;
args.row = cell.row;
args.columnDef = columnDef;
args.dataContext = dataContext;
args.grid = _grid;
if (!runOverrideFunctionWhenExists(_contextMenuProperties.menuUsabilityOverride, args)) {
return;
}
// create the DOM element
$menu = createMenu(e, args);
// reposition the menu to where the user clicked
if ($menu) {
repositionMenu(e);
$menu
.data("cell", _currentCell)
.data("row", _currentRow)
.show();
}
$("body").one("click", function (e) {
if(!e.isDefaultPrevented()) {
destroyMenu(e, { cell: _currentCell, row: _currentRow });
}
});
}
/** Construct the Option Items section. */
function populateOptionItems(contextMenu, optionMenuElm, optionItems, args) {
if (!args || !optionItems || !contextMenu) {
return;
}
// user could pass a title on top of the Options section
if (contextMenu && contextMenu.optionTitle) {
$optionTitleElm = $('<div class="title"/>').append(contextMenu.optionTitle);
$optionTitleElm.appendTo(optionMenuElm);
}
for (var i = 0, ln = optionItems.length; i < ln; i++) {
var item = optionItems[i];
// run each override functions to know if the item is visible and usable
var isItemVisible = runOverrideFunctionWhenExists(item.itemVisibilityOverride, args);
var isItemUsable = runOverrideFunctionWhenExists(item.itemUsabilityOverride, args);
// if the result is not visible then there's no need to go further
if (!isItemVisible) {
continue;
}
// when the override is defined, we need to use its result to update the disabled property
// so that "handleMenuItemOptionClick" has the correct flag and won't trigger an option clicked event
if (Object.prototype.hasOwnProperty.call(item, "itemUsabilityOverride")) {
item.disabled = isItemUsable ? false : true;
}
var $li = $('<div class="slick-context-menu-item"></div>')
.data("option", item.option !== undefined ? item.option : "")
.data("item", item)
.on("click", handleMenuItemOptionClick)
.appendTo(optionMenuElm);
if (item.divider || item === "divider") {
$li.addClass("slick-context-menu-item-divider");
continue;
}
// if the item is disabled then add the disabled css class
if (item.disabled || !isItemUsable) {
$li.addClass("slick-context-menu-item-disabled");
}
// if the item is hidden then add the hidden css class
if (item.hidden) {
$li.addClass("slick-context-menu-item-hidden");
}
if (item.cssClass) {
$li.addClass(item.cssClass);
}
if (item.tooltip) {
$li.attr("title", item.tooltip);
}
var $icon = $('<div class="slick-context-menu-icon"></div>')
.appendTo($li);
if (item.iconCssClass) {
$icon.addClass(item.iconCssClass);
}
if (item.iconImage) {
$icon.css("background-image", "url(" + item.iconImage + ")");
}
var $text = $('<span class="slick-context-menu-content"></span>')
.text(item.title)
.appendTo($li);
if (item.textCssClass) {
$text.addClass(item.textCssClass);
}
}
}
/** Construct the Command Items section. */
function populateCommandItems(contextMenu, commandMenuElm, commandItems, args) {
if (!args || !commandItems || !contextMenu) {
return;
}
// user could pass a title on top of the Commands section
if (contextMenu && contextMenu.commandTitle) {
$commandTitleElm = $('<div class="title"/>').append(contextMenu.commandTitle);
$commandTitleElm.appendTo(commandMenuElm);
}
for (var i = 0, ln = commandItems.length; i < ln; i++) {
var item = commandItems[i];
// run each override functions to know if the item is visible and usable
var isItemVisible = runOverrideFunctionWhenExists(item.itemVisibilityOverride, args);
var isItemUsable = runOverrideFunctionWhenExists(item.itemUsabilityOverride, args);
// if the result is not visible then there's no need to go further
if (!isItemVisible) {
continue;
}
// when the override is defined, we need to use its result to update the disabled property
// so that "handleMenuItemCommandClick" has the correct flag and won't trigger a command clicked event
if (Object.prototype.hasOwnProperty.call(item, "itemUsabilityOverride")) {
item.disabled = isItemUsable ? false : true;
}
var $li = $('<div class="slick-context-menu-item"></div>')
.data("command", item.command !== undefined ? item.command : "")
.data("item", item)
.on("click", handleMenuItemCommandClick)
.appendTo(commandMenuElm);
if (item.divider || item === "divider") {
$li.addClass("slick-context-menu-item-divider");
continue;
}
// if the item is disabled then add the disabled css class
if (item.disabled || !isItemUsable) {
$li.addClass("slick-context-menu-item-disabled");
}
// if the item is hidden then add the hidden css class
if (item.hidden) {
$li.addClass("slick-context-menu-item-hidden");
}
if (item.cssClass) {
$li.addClass(item.cssClass);
}
if (item.tooltip) {
$li.attr("title", item.tooltip);
}
var $icon = $('<div class="slick-context-menu-icon"></div>')
.appendTo($li);
if (item.iconCssClass) {
$icon.addClass(item.iconCssClass);
}
if (item.iconImage) {
$icon.css("background-image", "url(" + item.iconImage + ")");
}
var $text = $('<span class="slick-context-menu-content"></span>')
.text(item.title)
.appendTo($li);
if (item.textCssClass) {
$text.addClass(item.textCssClass);
}
}
}
function handleMenuItemCommandClick(e) {
var command = $(this).data("command");
var item = $(this).data("item");
if (!item || item.disabled || item.divider) {
return;
}
var row = $menu.data("row");
var cell = $menu.data("cell");
var columnDef = _grid.getColumns()[cell];
var dataContext = _grid.getDataItem(row);
var cellValue;
if (Object.prototype.hasOwnProperty.call(dataContext, columnDef && columnDef.field)) {
cellValue = dataContext[columnDef.field];
}
if (command != null && command !== "") {
// user could execute a callback through 2 ways
// via the onCommand event and/or an action callback
var callbackArgs = {
"cell": cell,
"row": row,
"grid": _grid,
"command": command,
"item": item,
"column": columnDef,
"dataContext": dataContext,
"value": cellValue
};
_self.onCommand.notify(callbackArgs, e, _self);
// execute action callback when defined
if (typeof item.action === "function") {
item.action.call(this, e, callbackArgs);
}
}
}
function handleMenuItemOptionClick(e) {
var option = $(this).data("option");
var item = $(this).data("item");
if (item.disabled || item.divider) {
return;
}
if (!_grid.getEditorLock().commitCurrentEdit()) {
return;
}
var row = $menu.data("row");
var cell = $menu.data("cell");
var columnDef = _grid.getColumns()[cell];
var dataContext = _grid.getDataItem(row);
if (option !== undefined) {
// user could execute a callback through 2 ways
// via the onOptionSelected event and/or an action callback
var callbackArgs = {
"cell": cell,
"row": row,
"grid": _grid,
"option": option,
"item": item,
"column": columnDef,
"dataContext": dataContext
};
_self.onOptionSelected.notify(callbackArgs, e, _self);
// execute action callback when defined
if (typeof item.action === "function") {
item.action.call(this, e, callbackArgs);
}
}
}
/**
* Reposition the menu drop (up/down) and the side (left/right)
* @param {*} event
*/
function repositionMenu(e) {
var $parent = $(e.target).closest(".slick-cell");
var menuOffsetLeft = e.pageX;
var menuOffsetTop = $parent ? $parent.offset().top : e.pageY;
var menuHeight = $menu.outerHeight() || 0;
var menuWidth = $menu.outerWidth() || _contextMenuProperties.width || 0;
var rowHeight = _gridOptions.rowHeight;
var dropOffset = _contextMenuProperties.autoAdjustDropOffset;
var sideOffset = _contextMenuProperties.autoAlignSideOffset;
// if autoAdjustDrop is enable, we first need to see what position the drop will be located
// without necessary toggling it's position just yet, we just want to know the future position for calculation
if (_contextMenuProperties.autoAdjustDrop) {
// since we reposition menu below slick cell, we need to take it in consideration and do our calculation from that element
var spaceBottom = calculateAvailableSpaceBottom($parent);
var spaceTop = calculateAvailableSpaceTop($parent);
var spaceBottomRemaining = spaceBottom + dropOffset - rowHeight;
var spaceTopRemaining = spaceTop - dropOffset + rowHeight;
var dropPosition = (spaceBottomRemaining < menuHeight && spaceTopRemaining > spaceBottomRemaining) ? 'top' : 'bottom';
if (dropPosition === 'top') {
$menu.removeClass("dropdown").addClass("dropup");
menuOffsetTop = menuOffsetTop - menuHeight - dropOffset;
} else {
$menu.removeClass("dropup").addClass("dropdown");
menuOffsetTop = menuOffsetTop + rowHeight + dropOffset;
}
}
// when auto-align is set, it will calculate whether it has enough space in the viewport to show the drop menu on the right (default)
// if there isn't enough space on the right, it will automatically align the drop menu to the left
// to simulate an align left, we actually need to know the width of the drop menu
if (_contextMenuProperties.autoAlignSide) {
var gridPos = _grid.getGridPosition();
var dropSide = ((menuOffsetLeft + menuWidth) >= gridPos.width) ? 'left' : 'right';
if (dropSide === 'left') {
$menu.removeClass("dropright").addClass("dropleft");
menuOffsetLeft = (menuOffsetLeft - menuWidth - sideOffset);
} else {
$menu.removeClass("dropleft").addClass("dropright");
menuOffsetLeft = menuOffsetLeft + sideOffset;
}
}
// ready to reposition the menu
$menu.css("top", menuOffsetTop);
$menu.css("left", menuOffsetLeft);
}
/**
* Method that user can pass to override the default behavior.
* In order word, user can choose or an item is (usable/visible/enable) by providing his own logic.
* @param overrideFn: override function callback
* @param args: multiple arguments provided to the override (cell, row, columnDef, dataContext, grid)
*/
function runOverrideFunctionWhenExists(overrideFn, args) {
if (typeof overrideFn === 'function') {
return overrideFn.call(this, args);
}
return true;
}
$.extend(this, {
"init": init,
"closeMenu": destroyMenu,
"destroy": destroy,
"pluginName": "ContextMenu",
"setOptions": setOptions,
"onAfterMenuShow": new Slick.Event(),
"onBeforeMenuShow": new Slick.Event(),
"onBeforeMenuClose": new Slick.Event(),
"onCommand": new Slick.Event(),
"onOptionSelected": new Slick.Event()
});
}
})(jQuery);