UNPKG

slickgrid

Version:

A lightning fast JavaScript grid/spreadsheet

700 lines (613 loc) 27.3 kB
(function ($) { // register namespace $.extend(true, window, { "Slick": { "Plugins": { "CellMenu": CellMenu } } }); /*** * A plugin to add Menu on a Cell click (click on the cell that has the cellMenu object defined) * The "cellMenu" is defined in a Column Definition object * Similar to the ContextMenu plugin (could be used in combo), * except that it subscribes to the cell "onClick" event (regular mouse click or touch). * * A general use of this plugin is for an Action Dropdown Menu to do certain things on the row that was clicked * You can use it to change the cell data property through a list of Options AND/OR through a list of Commands. * * USAGE: * * Add the slick.cellMenu.(js|css) files and register it with the grid. * * To specify a menu in a column header, extend the column definition like so: * var cellMenuPlugin = new Slick.Plugins.CellMenu(columns, grid, options); * * Available cellMenu options, by defining a cellMenu object: * * var columns = [ * { * id: "action", name: "Action", field: "action", formatter: fakeButtonFormatter, * cellMenu: { * optionTitle: "Change Effort Driven", * optionItems: [ * { option: true, title: "True", iconCssClass: 'checkmark' }, * { option: false, title: "False" } * ], * commandTitle: "Commands", * commandItems: [ * { command: "delete-row", title: "Delete Row", iconImage: "../images/delete.png", cssClass: 'bold', textCssClass: "red" }, * { divider: true }, * "divider" // you can pass "divider" as a string or an object * { command: "help", title: "Help", iconCssClass: "icon-help" }, * { command: "help", title: "Disabled Command", disabled: true }, * ], * } * } * ]; * * * Available cellMenu properties: * commandTitle: Title of the Command section (optional) * commandItems: Array of Command item objects (command/title pair) * optionTitle: Title of the Option section (optional) * optionItems: Array of Options item objects (option/title pair) * 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 true)? * 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 0) * 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 label. * divider: Boolean which tells 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) * * 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} Cell Menu Options * @class Slick.Plugins.CellMenu * @constructor */ function CellMenu(optionProperties) { var _cellMenuProperties; var _currentCell = -1; var _currentRow = -1; var _grid; var _gridOptions; var _gridUid = ""; var _handler = new Slick.EventHandler(); var _self = this; var $commandTitleElm; var $optionTitleElm; var $menu; var _defaults = { autoAdjustDrop: true, // dropup/dropdown autoAlignSide: true, // left/right autoAdjustDropOffset: 0, autoAlignSideOffset: 0, hideMenuOnScroll: true, maxHeight: "none", width: "auto", }; function init(grid) { _grid = grid; _gridOptions = grid.getOptions(); _cellMenuProperties = $.extend({}, _defaults, optionProperties); _gridUid = (grid && grid.getUID) ? grid.getUID() : ""; _handler.subscribe(_grid.onClick, handleCellClick); if (_cellMenuProperties.hideMenuOnScroll) { _handler.subscribe(_grid.onScroll, destroyMenu); } } function setOptions(newOptions) { _cellMenuProperties = $.extend({}, _cellMenuProperties, newOptions); } 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 commandItems = _cellMenuProperties.commandItems || []; var optionItems = _cellMenuProperties.optionItems || []; // make sure there's at least something to show before creating the Cell Menu if (!columnDef || !columnDef.cellMenu || (!commandItems.length && !optionItems.length)) { return; } // delete any prior Cell Menu destroyMenu(); // 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 cell menu var maxHeight = isNaN(_cellMenuProperties.maxHeight) ? _cellMenuProperties.maxHeight : _cellMenuProperties.maxHeight + "px"; var width = isNaN(_cellMenuProperties.width) ? _cellMenuProperties.width : _cellMenuProperties.width + "px"; var menuStyle = "width: " + width + "; max-height: " + maxHeight; var menu = $('<div class="slick-cell-menu ' + _gridUid + '" style="' + menuStyle + '" />') .css("top", e.pageY + 5) .css("left", e.pageX) .css("display", "none"); var closeButtonHtml = '<button type="button" class="close" data-dismiss="slick-cell-menu" aria-label="Close">' + '<span class="close" aria-hidden="true">&times;</span></button>'; // -- Option List section if (!_cellMenuProperties.hideOptionSection && optionItems.length > 0) { var $optionMenu = $('<div class="slick-cell-menu-option-list" />'); if (!_cellMenuProperties.hideCloseButton) { $(closeButtonHtml).on("click", handleCloseButtonClicked).appendTo(menu); } $optionMenu.appendTo(menu); populateOptionItems( _cellMenuProperties, $optionMenu, optionItems, { cell: _currentCell, row: _currentRow, column: columnDef, dataContext: dataContext, grid: _grid } ); } // -- Command List section if (!_cellMenuProperties.hideCommandSection && commandItems.length > 0) { var $commandMenu = $('<div class="slick-cell-menu-command-list" />'); if (!_cellMenuProperties.hideCloseButton && (optionItems.length === 0 || _cellMenuProperties.hideOptionSection)) { $(closeButtonHtml).on("click", handleCloseButtonClicked).appendTo(menu); } $commandMenu.appendTo(menu); populateCommandItems( _cellMenuProperties, $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 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 handleCloseButtonClicked(e) { if(!e.isDefaultPrevented()) { destroyMenu(e); } } function destroyMenu(e, args) { $menu = $menu || $(".slick-cell-menu." + _gridUid); if ($menu && $menu.remove) { if ($menu.length > 0) { if (_self.onBeforeMenuClose.notify({ "cell": args && args.cell, "row": args && args.row, "grid": _grid, "menu": $menu }, e, _self) == false) { return; } } $menu.remove(); $menu = null; } } /** * 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 = $parent ? $parent.offset().left : e.pageX; var menuOffsetTop = $parent ? $parent.offset().top : e.pageY; var parentCellWidth = $parent.outerWidth(); var menuHeight = $menu.outerHeight() || 0; var menuWidth = $menu.outerWidth() || _cellMenuProperties.width || 0; var rowHeight = _gridOptions.rowHeight; var dropOffset = _cellMenuProperties.autoAdjustDropOffset; var sideOffset = _cellMenuProperties.autoAlignSideOffset; // if autoAdjustDrop is enable, we first need to see what position the drop will be located (defaults to bottom) // without necessary toggling it's position just yet, we just want to know the future position for calculation if (_cellMenuProperties.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 (defaults to the right) // to simulate an align left, we actually need to know the width of the drop menu if (_cellMenuProperties.autoAlignSide) { var gridPos = _grid.getGridPosition(); var dropSide = ((menuOffsetLeft + menuWidth) >= gridPos.width) ? 'left' : 'right'; if (dropSide === 'left') { $menu.removeClass("dropright").addClass("dropleft"); menuOffsetLeft = (menuOffsetLeft - (menuWidth - parentCellWidth) - sideOffset); } else { $menu.removeClass("dropleft").addClass("dropright"); menuOffsetLeft = menuOffsetLeft + sideOffset; } } // ready to reposition the menu $menu.css("top", menuOffsetTop); $menu.css("left", menuOffsetLeft); } function handleCellClick(e, args) { var cell = _grid.getCellFromEvent(e); var dataContext = _grid.getDataItem(cell.row); var columnDef = _grid.getColumns()[cell.cell]; // prevent event from bubbling but only on column that has a cell menu defined if (columnDef && columnDef.cellMenu) { e.preventDefault(); } // merge the cellMenu of the column definition with the default properties _cellMenuProperties = $.extend({}, _cellMenuProperties, columnDef.cellMenu); // run the override function (when defined), if the result is false it won't go further if (!args) { args = {}; } args.columnDef = columnDef; args.dataContext = dataContext; args.grid = _grid; if (!runOverrideFunctionWhenExists(_cellMenuProperties.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(); } // Hide the menu on outside click. $("body").on("mousedown." + _gridUid, handleBodyMouseDown); } function handleBodyMouseDown(e) { if ($menu && $menu[0] != e.target && !$.contains($menu[0], e.target)) { if(!e.isDefaultPrevented()) { closeMenu(e, { cell: _currentCell, row: _currentRow }); } } } function closeMenu(e, args) { 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; } } } /** Construct the Option Items section. */ function populateOptionItems(cellMenu, optionMenuElm, optionItems, args) { if (!args || !optionItems || !cellMenu) { return; } // user could pass a title on top of the Options section if (cellMenu && cellMenu.optionTitle) { $optionTitleElm = $('<div class="title"/>').append(cellMenu.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-cell-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-cell-menu-item-divider"); continue; } // if the item is disabled then add the disabled css class if (item.disabled || !isItemUsable) { $li.addClass("slick-cell-menu-item-disabled"); } // if the item is hidden then add the hidden css class if (item.hidden) { $li.addClass("slick-cell-menu-item-hidden"); } if (item.cssClass) { $li.addClass(item.cssClass); } if (item.tooltip) { $li.attr("title", item.tooltip); } var $icon = $('<div class="slick-cell-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-cell-menu-content"></span>') .text(item.title) .appendTo($li); if (item.textCssClass) { $text.addClass(item.textCssClass); } } } /** Construct the Command Items section. */ function populateCommandItems(cellMenu, commandMenuElm, commandItems, args) { if (!args || !commandItems || !cellMenu) { return; } // user could pass a title on top of the Commands section if (cellMenu && cellMenu.commandTitle) { $commandTitleElm = $('<div class="title"/>').append(cellMenu.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-cell-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-cell-menu-item-divider"); continue; } // if the item is disabled then add the disabled css class if (item.disabled || !isItemUsable) { $li.addClass("slick-cell-menu-item-disabled"); } // if the item is hidden then add the hidden css class if (item.hidden) { $li.addClass("slick-cell-menu-item-hidden"); } if (item.cssClass) { $li.addClass(item.cssClass); } if (item.tooltip) { $li.attr("title", item.tooltip); } var $icon = $('<div class="slick-cell-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-cell-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 || item === "divider") { return; } var row = $menu.data("row"); var cell = $menu.data("cell"); var columnDef = _grid.getColumns()[cell]; var dataContext = _grid.getDataItem(row); 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 }; _self.onCommand.notify(callbackArgs, e, _self); // execute action callback when defined if (typeof item.action === "function") { item.action.call(this, e, callbackArgs); } if(!e.isDefaultPrevented()) { closeMenu(e, { cell: cell, row: row }); } } } function handleMenuItemOptionClick(e) { var option = $(this).data("option"); var item = $(this).data("item"); if (!item || item.disabled || item.divider || 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); } if(!e.isDefaultPrevented()) { closeMenu(e, { cell: cell, row: row }); } } } /** * 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": "CellMenu", "setOptions": setOptions, "onAfterMenuShow": new Slick.Event(), "onBeforeMenuShow": new Slick.Event(), "onBeforeMenuClose": new Slick.Event(), "onCommand": new Slick.Event(), "onOptionSelected": new Slick.Event() }); } })(jQuery);