slickgrid
Version:
A lightning fast JavaScript grid/spreadsheet
680 lines (597 loc) • 28.8 kB
JavaScript
/***
* A control to add a Grid Menu (hambuger menu on top-right of the grid)
*
* USAGE:
*
* Add the slick.gridmenu.(js|css) files and register it with the grid.
*
* To specify a menu in a column header, extend the column definition like so:
* var gridMenuControl = new Slick.Controls.GridMenu(columns, grid, options);
*
* Available grid options, by defining a gridMenu object:
*
* var options = {
* enableCellNavigation: true,
* gridMenu: {
* customTitle: "Custom Menus", // default to empty string
* columnTitle: "Columns", // default to empty string
* iconImage: "../images/drag-handle.png", // this is the Grid Menu icon (hamburger icon)
* iconCssClass: "fa fa-bars", // you can provide iconImage OR iconCssClass
* leaveOpen: false, // do we want to leave the Grid Menu open after a command execution? (false by default)
* menuWidth: 18, // width (icon) that will be use to resize the column header container (18 by default)
* contentMinWidth: 0, // defaults to 0 (auto), minimum width of grid menu content (command, column list)
* marginBottom: 15, // defaults to 15, margin to use at the bottom of the grid when using max-height (default)
* resizeOnShowHeaderRow: false, // false by default
* showButton: true, // true by default - it allows the user to control if the
* // default gridMenu button (located on the top right corner by default CSS)
* // should be created or omitted
* useClickToRepositionMenu: true, // true by default
*
* // the last 2 checkboxes titles
* hideForceFitButton: false, // show/hide checkbox near the end "Force Fit Columns"
* hideSyncResizeButton: false, // show/hide checkbox near the end "Synchronous Resize"
* forceFitTitle: "Force fit columns", // default to "Force fit columns"
* syncResizeTitle: "Synchronous resize", // default to "Synchronous resize"
*
* customItems: [
* {
* // custom menu item options
* },
* {
* // custom menu item options
* }
* ]
* }
* };
*
*
* Available menu options:
* hideForceFitButton: Hide the "Force fit columns" button (defaults to false)
* hideSyncResizeButton: Hide the "Synchronous resize" button (defaults to false)
* forceFitTitle: Text of the title "Force fit columns"
* contentMinWidth: minimum width of grid menu content (command, column list), defaults to 0 (auto)
* height: Height of the Grid Menu content, when provided it will be used instead of the max-height (defaults to undefined)
* menuWidth: Grid menu button width (defaults to 18)
* resizeOnShowHeaderRow: Do we want to resize on the show header row event
* syncResizeTitle: Text of the title "Synchronous resize"
* useClickToRepositionMenu: Use the Click offset to reposition the Grid Menu (defaults to true), when set to False it will use the icon offset to reposition the grid menu
* 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)
* marginBottom: Margin to use at the bottom of the grid menu, only in effect when height is undefined (defaults to 15)
*
* Available custom menu item options:
* action: Optionally define a callback function that gets executed when item is chosen (and/or use the onCommand event)
* title: Menu item text.
* divider: Whether the current item is a divider, not an actual command.
* disabled: Whether the item/command is disabled.
* hidden: Whether the item/command is hidden.
* tooltip: Item tooltip.
* command: A command identifier to be passed to the onCommand event handlers.
* cssClass: A CSS class to be added to the menu item container.
* iconCssClass: A CSS class to be added to the menu item icon.
* iconImage: A url to the icon image.
* textCssClass: A CSS class to be added to the menu item text.
* 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.
* * ONLY works with a jQuery event (as per slick.core code), so we cannot notify when it's a button event (when grid menu is attached to an external button, not the hamburger menu)
* Event args:
* grid: Reference to the grid.
* column: Column definition.
* menu: Menu options. Note that you can change the menu items here.
*
* onBeforeMenuShow: Fired before the menu is shown. You can customize the menu or dismiss it by returning false.
* * ONLY works with a jQuery event (as per slick.core code), so we cannot notify when it's a button event (when grid menu is attached to an external button, not the hamburger menu)
* Event args:
* grid: Reference to the grid.
* column: Column definition.
* menu: Menu options. Note that you can change the menu items here.
*
* onMenuClose: Fired when the menu is closing.
* Event args:
* grid: Reference to the grid.
* column: Column definition.
* menu: Menu options. Note that you can change the menu items here.
*
* onCommand: Fired on menu item click for buttons with 'command' specified.
* Event args:
* grid: Reference to the grid.
* column: Column definition.
* command: Button command identified.
* button: Button options. Note that you can change the button options in your
* event handler, and the column header will be automatically updated to
* reflect them. This is useful if you want to implement something like a
* toggle button.
*
*
* @param options {Object} Options:
* buttonCssClass: an extra CSS class to add to the menu button
* buttonImage: a url to the menu button image (default '../images/down.gif')
* @class Slick.Controls.GridMenu
* @constructor
*/
'use strict';
(function ($) {
// register namespace
$.extend(true, window, {
"Slick": {
"Controls": {
"GridMenu": SlickGridMenu
}
}
});
function SlickGridMenu(columns, grid, options) {
var _grid = grid;
var _gridOptions;
var _gridUid = (grid && grid.getUID) ? grid.getUID() : '';
var _isMenuOpen = false;
var _options = options;
var _self = this;
var $customTitleElm;
var $columnTitleElm;
var $customMenu;
var $header;
var $list;
var $button;
var $menu;
var columnCheckboxes;
var _defaults = {
showButton: true,
hideForceFitButton: false,
hideSyncResizeButton: false,
forceFitTitle: "Force fit columns",
marginBottom: 15,
menuWidth: 18,
contentMinWidth: 0,
resizeOnShowHeaderRow: false,
syncResizeTitle: "Synchronous resize",
useClickToRepositionMenu: true,
headerColumnValueExtractor: function (columnDef) {
return columnDef.name;
}
};
// when a grid optionally changes from a regular grid to a frozen grid, we need to destroy & recreate the grid menu
// we do this change because the Grid Menu is on the left container for a regular grid, it is however on the right container for a frozen grid
grid.onSetOptions.subscribe(function (e, args) {
if (args && args.optionsBefore && args.optionsAfter) {
var switchedFromRegularToFrozen = args.optionsBefore.frozenColumn >= 0 && args.optionsAfter.frozenColumn === -1;
var switchedFromFrozenToRegular = args.optionsBefore.frozenColumn === -1 && args.optionsAfter.frozenColumn >= 0;
if (switchedFromRegularToFrozen || switchedFromFrozenToRegular) {
recreateGridMenu();
}
}
});
function init(grid) {
_gridOptions = grid.getOptions();
createGridMenu();
// subscribe to the grid, when it's destroyed, we should also destroy the Grid Menu
grid.onBeforeDestroy.subscribe(destroy);
}
function setOptions(newOptions) {
options = $.extend({}, options, newOptions);
}
function createGridMenu() {
var gridMenuWidth = (_options.gridMenu && _options.gridMenu.menuWidth) || _defaults.menuWidth;
if (_gridOptions && _gridOptions.hasOwnProperty('frozenColumn') && _gridOptions.frozenColumn >= 0) {
$header = $('.' + _gridUid + ' .slick-header-right');
} else {
$header = $('.' + _gridUid + ' .slick-header-left');
}
$header.attr('style', 'width: calc(100% - ' + gridMenuWidth + 'px)');
// if header row is enabled, we need to resize it's width also
var enableResizeHeaderRow = (_options.gridMenu && _options.gridMenu.resizeOnShowHeaderRow != undefined) ? _options.gridMenu.resizeOnShowHeaderRow : _defaults.resizeOnShowHeaderRow;
if (enableResizeHeaderRow && _options.showHeaderRow) {
$('.' + _gridUid + '.slick-headerrow').attr('style', 'width: calc(100% - ' + gridMenuWidth + 'px)');
}
var showButton = (_options.gridMenu && _options.gridMenu.showButton !== undefined) ? _options.gridMenu.showButton : _defaults.showButton;
if (showButton) {
$button = $('<button class="slick-gridmenu-button"/>');
if (_options.gridMenu && _options.gridMenu.iconCssClass) {
$button.addClass(_options.gridMenu.iconCssClass);
} else {
var iconImage = (_options.gridMenu && _options.gridMenu.iconImage) ? _options.gridMenu.iconImage : "../images/drag-handle.png";
$('<img src="' + iconImage + '"/>').appendTo($button);
}
$button.insertBefore($header);
// add on click handler for the Grid Menu itself
$button.on("click." + _gridUid, showGridMenu);
}
$menu = $('<div class="slick-gridmenu ' + _gridUid + '" style="display: none" />').appendTo(document.body);
$('<button type="button" class="close" data-dismiss="slick-gridmenu" aria-label="Close"><span class="close" aria-hidden="true">×</span></button>').appendTo($menu);
$customMenu = $('<div class="slick-gridmenu-custom" />');
$customMenu.appendTo($menu);
populateCustomMenus(_options, $customMenu);
populateColumnPicker();
// Hide the menu on outside click.
$(document.body).on("mousedown." + _gridUid, handleBodyMouseDown);
// destroy the picker if user leaves the page
$(window).on("beforeunload", destroy);
}
/** Destroy the plugin by unsubscribing every events & also delete the menu DOM elements */
function destroy() {
_self.onAfterMenuShow.unsubscribe();
_self.onBeforeMenuShow.unsubscribe();
_self.onMenuClose.unsubscribe();
_self.onCommand.unsubscribe();
_self.onColumnsChanged.unsubscribe();
_grid.onColumnsReordered.unsubscribe(updateColumnOrder);
_grid.onBeforeDestroy.unsubscribe();
_grid.onSetOptions.unsubscribe();
$("div.slick-gridmenu." + _gridUid).remove();
deleteMenu();
$(window).off("beforeunload");
}
/** Delete the menu DOM element but without unsubscribing any events */
function deleteMenu() {
$(document.body).off("mousedown." + _gridUid, handleBodyMouseDown);
$("div.slick-gridmenu." + _gridUid).hide();
if ($button) {
$button.remove();
}
if ($menu) {
$menu.remove();
}
if ($customMenu) {
$customMenu.remove();
}
if ($header) {
$header.attr('style', 'width: 100%'); // put back original width
}
$customTitleElm = null;
$columnTitleElm = null;
$customMenu = null;
$header = null;
$list = null;
$button = null;
$menu = null;
}
function populateCustomMenus(options, $customMenu) {
// Construct the custom menu items.
if (!options.gridMenu || !options.gridMenu.customItems) {
return;
}
// user could pass a title on top of the custom section
if (_options.gridMenu && _options.gridMenu.customTitle) {
$customTitleElm = $('<div class="title"/>').append(_options.gridMenu.customTitle);
$customTitleElm.appendTo($customMenu);
}
for (var i = 0, ln = options.gridMenu.customItems.length; i < ln; i++) {
var item = options.gridMenu.customItems[i];
var callbackArgs = {
"grid": _grid,
"menu": $menu,
"columns": columns,
"visibleColumns": getVisibleColumns()
};
// run each override functions to know if the item is visible and usable
var isItemVisible = runOverrideFunctionWhenExists(item.itemVisibilityOverride, callbackArgs);
var isItemUsable = runOverrideFunctionWhenExists(item.itemUsabilityOverride, callbackArgs);
// 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-gridmenu-item'></div>")
.data("command", item.command !== undefined ? item.command : "")
.data("item", item)
.on("click", handleMenuItemClick)
.appendTo($customMenu);
if (item.divider || item === "divider") {
$li.addClass("slick-gridmenu-item-divider");
continue;
}
if (item.disabled) {
$li.addClass("slick-gridmenu-item-disabled");
}
if (item.hidden) {
$li.addClass("slick-gridmenu-item-hidden");
}
if (item.cssClass) {
$li.addClass(item.cssClass);
}
if (item.tooltip) {
$li.attr("title", item.tooltip);
}
var $icon = $("<div class='slick-gridmenu-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-gridmenu-content'></span>")
.text(item.title)
.appendTo($li);
if (item.textCssClass) {
$text.addClass(item.textCssClass);
}
$icon = null;
$li = null;
$text = null;
}
}
/** Build the column picker, the code comes almost untouched from the file "slick.columnpicker.js" */
function populateColumnPicker() {
_grid.onColumnsReordered.subscribe(updateColumnOrder);
_options = $.extend({}, _defaults, _options);
// user could pass a title on top of the columns list
if (_options.gridMenu && _options.gridMenu.columnTitle) {
$columnTitleElm = $('<div class="title"/>').append(_options.gridMenu.columnTitle);
$columnTitleElm.appendTo($menu);
}
$menu.on("click", updateColumn);
$list = $('<span class="slick-gridmenu-list" />');
}
/** Delete and then Recreate the Grid Menu (for example when we switch from regular to a frozen grid) */
function recreateGridMenu() {
deleteMenu();
init(_grid);
}
function showGridMenu(e) {
e.preventDefault();
// empty both the picker list & the command list
$list.empty();
$customMenu.empty();
populateCustomMenus(_options, $customMenu);
updateColumnOrder();
columnCheckboxes = [];
var callbackArgs = {
"grid": _grid,
"menu": $menu,
"allColumns": columns,
"visibleColumns": getVisibleColumns()
};
// run the override function (when defined), if the result is false it won't go further
if (_options && _options.gridMenu && !runOverrideFunctionWhenExists(_options.gridMenu.menuUsabilityOverride, callbackArgs)) {
return;
}
// notify of the onBeforeMenuShow only works when it's a jQuery event (as per slick.core code)
// this mean that we cannot notify when the grid menu is attach to a button event
if (typeof e.isPropagationStopped === "function") {
if (_self.onBeforeMenuShow.notify(callbackArgs, e, _self) == false) {
return;
}
}
var $li, $input, columnId, columnLabel, excludeCssClass;
for (var i = 0; i < columns.length; i++) {
columnId = columns[i].id;
excludeCssClass = columns[i].excludeFromGridMenu ? "hidden" : "";
$li = $('<li class="' + excludeCssClass + '" />').appendTo($list);
$input = $("<input type='checkbox' id='" + _gridUid + "-gridmenu-colpicker-" + columnId + "' />").data("column-id", columns[i].id).appendTo($li);
columnCheckboxes.push($input);
if (_grid.getColumnIndex(columns[i].id) != null) {
$input.attr("checked", "checked");
}
// get the column label from the picker value extractor (user can optionally provide a custom extractor)
if (_options && _options.gridMenu && _options.gridMenu.headerColumnValueExtractor) {
columnLabel = _options.gridMenu.headerColumnValueExtractor(columns[i], _gridOptions);
} else {
columnLabel = _defaults.headerColumnValueExtractor(columns[i], _gridOptions);
}
$("<label for='" + _gridUid + "-gridmenu-colpicker-" + columnId + "' />")
.html(columnLabel)
.appendTo($li);
}
if (_options.gridMenu && (!_options.gridMenu.hideForceFitButton || !_options.gridMenu.hideSyncResizeButton)) {
$("<hr/>").appendTo($list);
}
if (!(_options.gridMenu && _options.gridMenu.hideForceFitButton)) {
var forceFitTitle = (_options.gridMenu && _options.gridMenu.forceFitTitle) || _defaults.forceFitTitle;
$li = $("<li />").appendTo($list);
$input = $("<input type='checkbox' id='" + _gridUid + "-gridmenu-colpicker-forcefit' />").data("option", "autoresize").appendTo($li);
$("<label for='" + _gridUid + "-gridmenu-colpicker-forcefit' />").text(forceFitTitle).appendTo($li);
if (_grid.getOptions().forceFitColumns) {
$input.attr("checked", "checked");
}
}
if (!(_options.gridMenu && _options.gridMenu.hideSyncResizeButton)) {
var syncResizeTitle = (_options.gridMenu && _options.gridMenu.syncResizeTitle) || _defaults.syncResizeTitle;
$li = $("<li />").appendTo($list);
$input = $("<input type='checkbox' id='" + _gridUid + "-gridmenu-colpicker-syncresize' />").data("option", "syncresize").appendTo($li);
$("<label for='" + _gridUid + "-gridmenu-colpicker-syncresize' />").text(syncResizeTitle).appendTo($li);
if (_grid.getOptions().syncColumnCellResize) {
$input.attr("checked", "checked");
}
}
var menuIconOffset = $(e.target).prop('nodeName') == "button" ? $(e.target).offset() : $(e.target).parent("button").offset(); // get button offset position
if (!menuIconOffset) {
menuIconOffset = $(e.target).offset(); // external grid menu might fall in this last case
}
var menuWidth = $menu.width();
var useClickToRepositionMenu = (_options.gridMenu && _options.gridMenu.useClickToRepositionMenu !== undefined) ? _options.gridMenu.useClickToRepositionMenu : _defaults.useClickToRepositionMenu;
var gridMenuIconWidth = (_options.gridMenu && _options.gridMenu.menuWidth) || _defaults.menuWidth;
var contentMinWidth = (_options.gridMenu && _options.gridMenu.contentMinWidth) ? _options.gridMenu.contentMinWidth : _defaults.contentMinWidth;
var currentMenuWidth = (contentMinWidth > menuWidth) ? contentMinWidth : (menuWidth + gridMenuIconWidth);
var nextPositionTop = (useClickToRepositionMenu && e.pageY > 0) ? e.pageY : menuIconOffset.top + 10;
var nextPositionLeft = (useClickToRepositionMenu && e.pageX > 0) ? e.pageX : menuIconOffset.left + 10;
var menuMarginBottom = (_options.gridMenu && _options.gridMenu.marginBottom !== undefined) ? _options.gridMenu.marginBottom : _defaults.marginBottom;
$menu
.css("top", nextPositionTop + 10)
.css("left", nextPositionLeft - currentMenuWidth + 10);
if (contentMinWidth > 0) {
$menu.css("min-width", contentMinWidth);
}
// set "height" when defined OR ELSE use the "max-height" with available window size and optional margin bottom
if (_options.gridMenu && _options.gridMenu.height !== undefined) {
$menu.css("height", _options.gridMenu.height);
} else {
$menu.css("max-height", $(window).height() - e.clientY - menuMarginBottom);
}
$menu.show();
$list.appendTo($menu);
_isMenuOpen = true;
if (typeof e.isPropagationStopped === "function") {
if (_self.onAfterMenuShow.notify(callbackArgs, e, _self) == false) {
return;
}
}
$input = null;
}
function handleBodyMouseDown(e) {
if (($menu && $menu[0] != e.target && !$.contains($menu[0], e.target) && _isMenuOpen) || e.target.className == "close") {
hideMenu(e);
}
}
function handleMenuItemClick(e) {
var command = $(this).data("command");
var item = $(this).data("item");
if (item.disabled || item.divider || item === "divider") {
return;
}
if (command != null && command != '') {
var callbackArgs = {
"grid": _grid,
"command": command,
"item": item,
"allColumns": columns,
"visibleColumns": getVisibleColumns()
};
_self.onCommand.notify(callbackArgs, e, _self);
// execute action callback when defined
if (typeof item.action === "function") {
item.action.call(this, e, callbackArgs);
}
}
// does the user want to leave open the Grid Menu after executing a command?
var leaveOpen = (_options.gridMenu && _options.gridMenu.leaveOpen) ? true : false;
if (!leaveOpen && !e.isDefaultPrevented()) {
hideMenu(e);
}
// Stop propagation so that it doesn't register as a header click event.
e.preventDefault();
e.stopPropagation();
}
function hideMenu(e) {
if ($menu) {
$menu.hide();
_isMenuOpen = false;
var callbackArgs = {
"grid": _grid,
"menu": $menu,
"allColumns": columns,
"visibleColumns": getVisibleColumns()
};
if (_self.onMenuClose.notify(callbackArgs, e, _self) == false) {
return;
}
}
}
/** Update the Titles of each sections (command, customTitle, ...) */
function updateAllTitles(gridMenuOptions) {
if ($customTitleElm && $customTitleElm.text) {
$customTitleElm.text(gridMenuOptions.customTitle);
}
if ($columnTitleElm && $columnTitleElm.text) {
$columnTitleElm.text(gridMenuOptions.columnTitle);
}
}
function updateColumnOrder() {
// Because columns can be reordered, we have to update the `columns`
// to reflect the new order, however we can't just take `grid.getColumns()`,
// as it does not include columns currently hidden by the picker.
// We create a new `columns` structure by leaving currently-hidden
// columns in their original ordinal position and interleaving the results
// of the current column sort.
var current = _grid.getColumns().slice(0);
var ordered = new Array(columns.length);
for (var i = 0; i < ordered.length; i++) {
if (_grid.getColumnIndex(columns[i].id) === undefined) {
// If the column doesn't return a value from getColumnIndex,
// it is hidden. Leave it in this position.
ordered[i] = columns[i];
} else {
// Otherwise, grab the next visible column.
ordered[i] = current.shift();
}
}
columns = ordered;
}
function updateColumn(e) {
if ($(e.target).data("option") == "autoresize") {
// when calling setOptions, it will resize with ALL Columns (even the hidden ones)
// we can avoid this problem by keeping a reference to the visibleColumns before setOptions and then setColumns after
var previousVisibleColumns = getVisibleColumns();
var isChecked = e.target.checked;
_grid.setOptions({ forceFitColumns: isChecked });
_grid.setColumns(previousVisibleColumns);
return;
}
if ($(e.target).data("option") == "syncresize") {
if (e.target.checked) {
_grid.setOptions({ syncColumnCellResize: true });
} else {
_grid.setOptions({ syncColumnCellResize: false });
}
return;
}
if ($(e.target).is(":checkbox")) {
var isChecked = e.target.checked;
var columnId = $(e.target).data("column-id") || "";
var visibleColumns = [];
$.each(columnCheckboxes, function (i) {
if ($(this).is(":checked")) {
visibleColumns.push(columns[i]);
}
});
if (!visibleColumns.length) {
$(e.target).attr("checked", "checked");
return;
}
var callbackArgs = {
"columnId": columnId,
"showing": isChecked,
"grid": _grid,
"allColumns": columns,
"columns": visibleColumns
};
_grid.setColumns(visibleColumns);
_self.onColumnsChanged.notify(callbackArgs, e, _self);
}
}
init(_grid);
function getAllColumns() {
return columns;
}
/** visible columns, we can simply get them directly from the grid */
function getVisibleColumns() {
return _grid.getColumns();
}
/**
* 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,
"getAllColumns": getAllColumns,
"getVisibleColumns": getVisibleColumns,
"destroy": destroy,
"deleteMenu": deleteMenu,
"recreateGridMenu": recreateGridMenu,
"showGridMenu": showGridMenu,
"setOptions": setOptions,
"updateAllTitles": updateAllTitles,
"hideMenu": hideMenu,
"onAfterMenuShow": new Slick.Event(),
"onBeforeMenuShow": new Slick.Event(),
"onMenuClose": new Slick.Event(),
"onCommand": new Slick.Event(),
"onColumnsChanged": new Slick.Event()
});
}
})(jQuery);