UNPKG

slickgrid

Version:

A lightning fast JavaScript grid/spreadsheet

680 lines (597 loc) 28.8 kB
/*** * 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">&times;</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);