import type { BasePubSubService } from '@slickgrid-universal/event-pub-sub'; import { createDomElement, emptyElement, extend, findWidthOrDefault, getHtmlStringOutput } from '@slickgrid-universal/utils'; import { SlickEvent, type SlickEventData, type SlickGrid } from '../core/index.js'; import type { Column, ColumnPicker, ColumnPickerOption, DOMMouseOrTouchEvent, GridOption, OnColumnsChangedArgs, } from '../interfaces/index.js'; import type { SharedService } from '../services/shared.service.js'; import type { ExtensionUtility } from './extensionUtility.js'; import { wireMenuKeyboardNavigation } from './keyboardNavigation.js'; import { MenuBaseClass } from './menuBaseClass.js'; /** * A control to add a Column Picker (right+click on any column header to reveal the column picker) * To specify a custom button in a column header, extend the column definition like so: * this.gridOptions = { * enableColumnPicker: true, * columnPicker: { * ... column picker options ... * } * }]; * @class ColumnPickerControl * @constructor */ export class SlickColumnPicker extends MenuBaseClass { onColumnsChanged: SlickEvent; protected _originalPickerOptions!: ColumnPicker; protected _defaults = { // the last 2 checkboxes titles hideForceFitButton: false, hideSyncResizeButton: false, forceFitTitle: 'Force fit columns', minHeight: 150, syncResizeTitle: 'Synchronous resize', headerColumnValueExtractor: (columnDef: Column) => { return getHtmlStringOutput(columnDef.columnPickerLabel || columnDef.name || '', 'innerHTML'); }, } as ColumnPickerOption; /** Constructor of the SlickGrid 3rd party plugin, it can optionally receive options */ constructor(extensionUtility: ExtensionUtility, pubSubService: BasePubSubService, sharedService: SharedService) { super(extensionUtility, pubSubService, sharedService); this._camelPluginName = 'columnPicker'; this._menuPluginCssPrefix = 'slick-column-picker'; this.onColumnsChanged = new SlickEvent('onColumnsChanged'); this._columns = this.grid?.getColumns() ?? []; this._gridUid = this.grid?.getUID() ?? ''; this.init(); } get addonOptions(): ColumnPicker { return this.gridOptions.columnPicker || {}; } get columns(): Column[] { return this._columns; } set columns(newColumns: Column[]) { this._columns = newColumns; } get gridOptions(): GridOption { return this.sharedService.gridOptions ?? {}; } get grid(): SlickGrid { return this.sharedService.slickGrid; } get menuElement(): HTMLDivElement | null { return this._menuElm || null; } /** Initialize plugin. */ init(): void { this._gridUid = this.grid.getUID() ?? ''; // keep original user grid menu, useful when switching locale to translate this._originalPickerOptions = extend(true, {}, this.sharedService.gridOptions.columnPicker); this.gridOptions.columnPicker = { ...this._defaults, ...this.gridOptions.columnPicker }; // add PubSub instance to all SlickEvent this.onColumnsChanged.setPubSubService(this.pubSubService); // localization support for the picker this.translateColumnPickerTitles(); this._eventHandler.subscribe(this.grid.onPreHeaderContextMenu, (e) => { if (['slick-column-name', 'slick-header-column'].some((className) => e.target?.classList.contains(className))) { this.handleHeaderContextMenu(e); // open picker only when preheader has column groups } }); this._eventHandler.subscribe(this.grid.onHeaderContextMenu, this.handleHeaderContextMenu.bind(this)); this._eventHandler.subscribe(this.grid.onColumnsReordered, this.updateColumnPickerOrder.bind(this)); this._eventHandler.subscribe(this.grid.onClick, this.disposeMenu.bind(this)); // Hide the menu on outside click. this._bindEventService.bind(document.body, 'mousedown', this.handleBodyMouseDown.bind(this) as EventListener, undefined, 'body'); // destroy the picker if user leaves the page this._bindEventService.bind(document.body, 'beforeunload', this.dispose.bind(this) as EventListener, undefined, 'body'); } /** Dispose (destroy) the SlickGrid 3rd party plugin */ dispose(): void { this._eventHandler.unsubscribeAll(); this._bindEventService.unbindAll(); this.disposeMenu(); } disposeMenu(): void { this._listElm?.remove(); this._menuElm?.remove(); this._menuElm = null; } createPickerMenu(): HTMLDivElement { const menuElm = createDomElement('div', { className: `slick-column-picker ${this._gridUid}`, role: 'menu', }); this.updateColumnPickerOrder(); // add Close button and optiona a Column list title this.addColumnTitleElementWhenDefined(menuElm); this.addCloseButtomElement(menuElm); this._listElm = createDomElement('div', { className: 'slick-column-picker-list', role: 'menu' }); this._bindEventService.bind(menuElm, 'click', this.handleColumnPickerItemClick.bind(this) as EventListener, undefined, 'parent-menu'); document.body.appendChild(menuElm); return menuElm; } /** * Get all columns including hidden columns. * @returns {Array} - all columns array */ getAllColumns(): Column[] { return this._columns; } /** * Get only the visible columns. * @returns {Array} - all columns array */ getVisibleColumns(): Column[] { return this.grid.getVisibleColumns(); } /** Translate the Column Picker headers and also the last 2 checkboxes */ translateColumnPicker(): void { this.translateColumnPickerTitles(); // translate all columns (including hidden columns) this.extensionUtility.translateItems(this._columns, 'nameKey', 'name'); } // update the properties by pointers, that is the only way to get Column Picker Control to see the new values translateColumnPickerTitles(): void { if (this.addonOptions) { this.addonOptions.columnTitle = this._originalPickerOptions.columnTitle || ''; this.addonOptions.forceFitTitle = this._originalPickerOptions.forceFitTitle || ''; this.addonOptions.syncResizeTitle = this._originalPickerOptions.syncResizeTitle || ''; this.addonOptions.columnTitle = this.extensionUtility.getPickerTitleOutputString('columnTitle', 'columnPicker'); this.addonOptions.forceFitTitle = this.extensionUtility.getPickerTitleOutputString('forceFitTitle', 'columnPicker'); this.addonOptions.syncResizeTitle = this.extensionUtility.getPickerTitleOutputString('syncResizeTitle', 'columnPicker'); } } // -- // protected functions // ------------------ /** Mouse down handler when clicking anywhere in the DOM body */ protected handleBodyMouseDown(e: DOMMouseOrTouchEvent): void { // prettier-ignore if ((this._menuElm !== e.target && !this._menuElm?.contains(e.target)) || (e.target.className === 'close' && e.target.closest('.slick-column-picker'))) { this.disposeMenu(); } } /** Mouse header context handler when doing a right+click on any of the header column title */ protected handleHeaderContextMenu(e: SlickEventData): void { e.preventDefault(); emptyElement(this._menuElm); this._columnCheckboxes = []; this._menuElm = this.createPickerMenu(); // add dark mode CSS class when enabled if (this.gridOptions.darkMode) { this._menuElm.classList.add('slick-dark-mode'); } // load the column & create column picker list this.populateColumnPicker(this.addonOptions, this._defaults.headerColumnValueExtractor); document.body.appendChild(this._menuElm); // Reposition menu (this also appends _listElm to _menuElm) this.repositionMenu(e); // Focus the first menu item BEFORE binding keyboard handler this.focusFirstMenuItem(this._menuElm); // Use shared utility to wire up keyboard navigation with custom onActivate wireMenuKeyboardNavigation(this._menuElm, this._bindEventService, { allItemsSelector: '.slick-column-picker-list li:not(.hidden)', focusedItemSelector: '.slick-column-picker-list li:not(.hidden)', onActivate: (focusedItem) => { // For column picker items, trigger click on the icon-checkbox-container div inside the li if (focusedItem) { const iconContainer = focusedItem.querySelector('.icon-checkbox-container') as HTMLElement; if (iconContainer && typeof iconContainer.click === 'function') { iconContainer.click(); } } }, onEscape: () => { this.disposeMenu(); this.grid.focus('header'); }, onTab: (evt) => { evt.preventDefault(); evt.stopPropagation(); }, eventServiceKey: 'column-picker-keyboard', }); } protected focusFirstMenuItem(menuElm: HTMLElement): void { // Get all column picker items and find the first one that's not hidden const menuItems = Array.from(menuElm.querySelectorAll('.slick-column-picker-list li:not(.hidden)')) as HTMLElement[]; const firstMenuItem = menuItems.find((item) => item.offsetParent !== null); firstMenuItem?.focus(); } repositionMenu(event: DOMMouseOrTouchEvent | SlickEventData): void { const targetEvent: MouseEvent | Touch = (event as TouchEvent)?.touches?.[0] ?? event; if (this._menuElm) { // auto-positioned menu left/right by available viewport space const gridPos = this.grid.getGridPosition(); const menuWidth = this._menuElm.clientWidth || 0; let menuOffsetLeft = targetEvent.pageX || 0; if (gridPos?.width && menuOffsetLeft + menuWidth >= gridPos.width) { menuOffsetLeft = menuOffsetLeft - menuWidth; } this._menuElm.style.top = `${targetEvent.pageY - 10}px`; this._menuElm.style.left = `${menuOffsetLeft}px`; this._menuElm.style.minHeight = findWidthOrDefault(this.addonOptions.minHeight, ''); this._menuElm.style.maxHeight = findWidthOrDefault(this.addonOptions.maxHeight, `${window.innerHeight - targetEvent.clientY}px`); this._menuElm.style.display = 'block'; this._menuElm.appendChild(this._listElm); } } }