handsontable
Version:
Handsontable is a JavaScript Data Grid available for React, Angular and Vue.
600 lines (589 loc) • 24.3 kB
JavaScript
import "core-js/modules/es.error.cause.js";
import "core-js/modules/es.array.at.js";
import "core-js/modules/es.string.at-alternative.js";
import "core-js/modules/esnext.iterator.constructor.js";
import "core-js/modules/esnext.iterator.every.js";
function _classPrivateMethodInitSpec(e, a) { _checkPrivateRedeclaration(e, a), a.add(e); }
function _classPrivateFieldInitSpec(e, t, a) { _checkPrivateRedeclaration(e, t), t.set(e, a); }
function _checkPrivateRedeclaration(e, t) { if (t.has(e)) throw new TypeError("Cannot initialize the same private elements twice on an object"); }
function _classPrivateFieldSet(s, a, r) { return s.set(_assertClassBrand(s, a), r), r; }
function _classPrivateFieldGet(s, a) { return s.get(_assertClassBrand(s, a)); }
function _assertClassBrand(e, t, n) { if ("function" == typeof e ? e === t : e.has(t)) return arguments.length < 3 ? t : n; throw new TypeError("Private element is not present on this object"); }
import { BasePlugin } from "../base/index.mjs";
import { DialogUI } from "./ui.mjs";
import { isObject } from "../../helpers/object.mjs";
import * as C from "../../i18n/constants.mjs";
export const PLUGIN_KEY = 'dialog';
export const PLUGIN_PRIORITY = 360;
const SHORTCUTS_GROUP = PLUGIN_KEY;
const SHORTCUTS_CONTEXT_NAME = `plugin:${PLUGIN_KEY}`;
/* eslint-disable jsdoc/require-description-complete-sentence */
/**
* @plugin Dialog
* @class Dialog
*
* @description
* The dialog plugin provides a modal dialog system for Handsontable. It allows you to display custom content in modal dialogs
* that overlay the table, providing a way to show notifications, error messages, loading indicators, or any other interactive content.
*
* In order to enable the dialog mechanism, {@link Options#dialog} option must be set to `true`.
*
* The plugin provides several configuration options to customize the dialog behavior and appearance:
* - `template`: The template to use for the dialog (default: `null`). The error will be thrown when
* the template is provided together with the `content` option.
* - `type`: The type of the template ('confirm')
* - `title`: The title of the dialog
* - `description`: The description of the dialog (default: '')
* - `buttons`: The buttons to display in the dialog (default: [])
* - `text`: The text of the button
* - `type`: The type of the button ('primary' | 'secondary')
* - `callback`: The callback to trigger when the button is clicked
* - `content`: The string or HTMLElement content to display in the dialog (default: '')
* - `customClassName`: Custom class name to apply to the dialog (default: '')
* - `background`: Dialog background variant 'solid' | 'semi-transparent' (default: 'solid')
* - `contentBackground`: Whether to show content background (default: false)
* - `animation`: Whether to enable animations (default: true)
* - `closable`: Whether the dialog can be closed (default: false)
* - `a11y`: Object with accessibility options (default object below)
* - `role`: The role of the dialog ('dialog' | 'alertdialog') (default: 'dialog')
* - `ariaLabel`: The label of the dialog (default: 'Dialog')
* - `ariaLabelledby`: The ID of the element that labels the dialog (default: '')
* - `ariaDescribedby`: The ID of the element that describes the dialog (default: ''),
*
* @example
*
* ::: only-for javascript
* ```js
* // Enable dialog plugin with default options
* dialog: true,
*
* // Enable dialog plugin with custom configuration
* dialog: {
* content: 'Dialog content',
* customClassName: 'custom-dialog',
* background: 'semi-transparent',
* contentBackground: false,
* animation: false,
* closable: true,
* a11y: {
* role: 'dialog',
* ariaLabel: 'Dialog',
* ariaLabelledby: 'titleID',
* ariaDescribedby: 'descriptionID',
* }
* }
*
* // Enable dialog plugin using prebuild templates
* dialog: {
* template: {
* type: 'confirm',
* title: 'Confirm',
* description: 'This is a confirm',
* buttons: [
* {
* text: 'Ok',
* type: 'primary',
* callback: () => {
* console.log('Ok');
* }
* },
* {
* text: 'Cancel',
* type: 'secondary',
* callback: () => {
* console.log('Cancel');
* }
* },
* ],
* },
* }
*
* // Access to dialog plugin instance:
* const dialogPlugin = hot.getPlugin('dialog');
*
* // Show a dialog programmatically:
* dialogPlugin.show({
* content: '<h2>Custom Dialog</h2><p>This is a custom dialog content.</p>',
* closable: true,
* });
*
* // Hide the dialog programmatically:
* dialogPlugin.hide();
*
* // Check if dialog is visible:
* const isVisible = dialogPlugin.isVisible();
* ```
* :::
*
* ::: only-for react
* ```jsx
* const MyComponent = () => {
* const hotRef = useRef(null);
*
* useEffect(() => {
* const hot = hotRef.current.hotInstance;
* const dialogPlugin = hot.getPlugin('dialog');
*
* dialogPlugin.show({
* content: <div>
* <h2>React Dialog</h2>
* <p>Dialog content rendered with React</p>
* </div>,
* closable: true
* });
* }, []);
*
* return (
* <HotTable
* ref={hotRef}
* settings={{
* data: data,
* dialog: {
* customClassName: 'react-dialog',
* closable: true
* }
* }}
* />
* );
* }
* ```
* :::
*
* ::: only-for angular
* ```ts
* hotSettings: Handsontable.GridSettings = {
* data: data,
* dialog: {
* customClassName: 'angular-dialog',
* closable: true
* }
* }
* ```
*
* ```html
* <hot-table
* [settings]="hotSettings">
* </hot-table>
* ```
* :::
*/
var _ui = /*#__PURE__*/new WeakMap();
var _isVisible = /*#__PURE__*/new WeakMap();
var _selectionState = /*#__PURE__*/new WeakMap();
var _Dialog_brand = /*#__PURE__*/new WeakSet();
export class Dialog extends BasePlugin {
constructor() {
super(...arguments);
/**
* Register shortcuts responsible for closing the dialog and navigating through the dialog.
*/
_classPrivateMethodInitSpec(this, _Dialog_brand);
/**
* UI instance of the dialog plugin.
*
* @type {DialogUI}
*/
_classPrivateFieldInitSpec(this, _ui, null);
/**
* Flag indicating if dialog is currently visible.
*
* @type {boolean}
*/
_classPrivateFieldInitSpec(this, _isVisible, false);
/**
* Keeps the selection state that will be restored after the dialog is closed.
*
* @type {SelectionState | null}
*/
_classPrivateFieldInitSpec(this, _selectionState, null);
}
static get PLUGIN_KEY() {
return PLUGIN_KEY;
}
static get PLUGIN_PRIORITY() {
return PLUGIN_PRIORITY;
}
static get DEFAULT_SETTINGS() {
return {
template: null,
content: '',
customClassName: '',
background: 'solid',
contentBackground: false,
animation: true,
closable: false,
a11y: {
role: 'dialog',
ariaLabel: 'Dialog',
ariaLabelledby: '',
ariaDescribedby: ''
}
};
}
static get SETTINGS_VALIDATORS() {
return {
template: value => isObject(value) && typeof ['alert', 'confirm'].includes(value.type) && typeof value.title === 'string' && (typeof (value === null || value === void 0 ? void 0 : value.description) === 'undefined' || typeof (value === null || value === void 0 ? void 0 : value.description) === 'string') && (typeof (value === null || value === void 0 ? void 0 : value.buttons) === 'undefined' || Array.isArray(value === null || value === void 0 ? void 0 : value.buttons) && value.buttons.every(item => typeof item === 'object' && typeof item.text === 'string' && ['primary', 'secondary'].includes(item.type) && (typeof item.callback === 'undefined' || typeof item.callback === 'function'))),
content: value => typeof value === 'string' || typeof HTMLElement !== 'undefined' && value instanceof HTMLElement || typeof DocumentFragment !== 'undefined' && value instanceof DocumentFragment,
customClassName: value => typeof value === 'string',
background: value => ['solid', 'semi-transparent'].includes(value),
contentBackground: value => typeof value === 'boolean',
animation: value => typeof value === 'boolean',
closable: value => typeof value === 'boolean',
a11y: value => isObject(value) && (typeof (value === null || value === void 0 ? void 0 : value.role) === 'undefined' || ['dialog', 'alertdialog'].includes(value === null || value === void 0 ? void 0 : value.role)) && (typeof (value === null || value === void 0 ? void 0 : value.ariaLabel) === 'undefined' || typeof (value === null || value === void 0 ? void 0 : value.ariaLabel) === 'string') && (typeof (value === null || value === void 0 ? void 0 : value.ariaLabelledby) === 'undefined' || typeof (value === null || value === void 0 ? void 0 : value.ariaLabelledby) === 'string') && (typeof (value === null || value === void 0 ? void 0 : value.ariaDescribedby) === 'undefined' || typeof (value === null || value === void 0 ? void 0 : value.ariaDescribedby) === 'string')
};
}
/**
* Check if the plugin is enabled in the handsontable settings.
*
* @returns {boolean}
*/
isEnabled() {
return !!this.hot.getSettings()[PLUGIN_KEY];
}
/**
* Enable plugin for this Handsontable instance.
*/
enablePlugin() {
if (this.enabled) {
return;
}
if (!_classPrivateFieldGet(_ui, this)) {
_classPrivateFieldSet(_ui, this, new DialogUI({
rootElement: this.hot.rootGridElement,
isRtl: this.hot.isRtl()
}));
}
_assertClassBrand(_Dialog_brand, this, _registerShortcuts).call(this);
_assertClassBrand(_Dialog_brand, this, _registerFocusScope).call(this);
this.addHook('afterViewRender', () => _assertClassBrand(_Dialog_brand, this, _onAfterViewRender).call(this));
super.enablePlugin();
}
/**
* Update plugin state after Handsontable settings update.
*/
updatePlugin() {
this.disablePlugin();
this.enablePlugin();
super.updatePlugin();
}
/**
* Disable plugin for this Handsontable instance.
*/
disablePlugin() {
this.hide();
_assertClassBrand(_Dialog_brand, this, _unregisterShortcuts).call(this);
_assertClassBrand(_Dialog_brand, this, _unregisterFocusScope).call(this);
super.disablePlugin();
}
/**
* Check if the dialog is currently visible.
*
* @returns {boolean} True if the dialog is visible, false otherwise.
*/
isVisible() {
return _classPrivateFieldGet(_isVisible, this);
}
/**
* Show dialog with given configuration.
* Displays the dialog with the specified content and options.
*
* @param {object} options Dialog configuration object containing content and display options.
* @param {object} options.template The template to use for the dialog (default: `null`). The error will be thrown when
* the template is provided together with the `content` option.
* @param {'confirm'} options.template.type The type of the template ('confirm').
* @param {string} options.template.title The title of the dialog.
* @param {string} options.template.description The description of the dialog. Default: ''.
* @param {object[]} options.template.buttons The buttons to display in the dialog. Default: [].
* @param {string} options.template.buttons.text The text of the button.
* @param {'primary' | 'secondary'} options.template.buttons.type The type of the button.
* @param {function(MouseEvent)} options.template.buttons.callback The callback to trigger when the button is clicked.
* @param {string|HTMLElement|DocumentFragment} options.content The content to display in the dialog. Can be a string, HTMLElement, or DocumentFragment. Default: ''
* @param {string} options.customClassName Custom CSS class name to apply to the dialog container. Default: ''
* @param {'solid'|'semi-transparent'} options.background Dialog background variant. Default: 'solid'.
* @param {boolean} options.contentBackground Whether to show content background. Default: false.
* @param {boolean} options.animation Whether to enable animations when showing/hiding the dialog. Default: true.
* @param {boolean} options.closable Whether the dialog can be closed by user interaction. Default: false.
* @param {object} options.a11y Object with accessibility options.
* @param {string} options.a11y.role The role of the dialog. Default: 'dialog'.
* @param {string} options.a11y.ariaLabel The label of the dialog. Default: 'Dialog'.
* @param {string} options.a11y.ariaLabelledby The ID of the element that labels the dialog. Default: ''.
* @param {string} options.a11y.ariaDescribedby The ID of the element that describes the dialog. Default: ''.
*/
show() {
let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
if (!this.enabled) {
return;
}
if (this.isVisible()) {
this.update(options);
return;
}
this.hot.runHooks('beforeDialogShow');
this.update(options);
_classPrivateFieldGet(_ui, this).showDialog(this.getSetting('animation'));
_classPrivateFieldSet(_isVisible, this, true);
this.hot.getFocusScopeManager().activateScope(PLUGIN_KEY);
_classPrivateFieldSet(_selectionState, this, this.hot.selection.exportSelection());
this.hot.deselectCell();
this.hot.runHooks('afterDialogShow');
}
/**
* Hide the currently open dialog.
* Closes the dialog and restores the focus to the table.
*/
hide() {
var _classPrivateFieldGet2;
if (!this.isVisible()) {
return;
}
this.hot.runHooks('beforeDialogHide');
_classPrivateFieldGet(_ui, this).hideDialog(this.getSetting('animation'));
_classPrivateFieldSet(_isVisible, this, false);
this.hot.getFocusScopeManager().deactivateScope(PLUGIN_KEY);
if (((_classPrivateFieldGet2 = _classPrivateFieldGet(_selectionState, this)) === null || _classPrivateFieldGet2 === void 0 ? void 0 : _classPrivateFieldGet2.ranges.length) > 0) {
this.hot.selection.importSelection(_classPrivateFieldGet(_selectionState, this));
this.hot.view.render();
_classPrivateFieldSet(_selectionState, this, null);
} else {
this.hot.selectCell(0, 0);
}
this.hot.runHooks('afterDialogHide');
}
/**
* Update the dialog configuration.
*
* @param {object} options Dialog configuration object containing content and display options.
* @param {object} options.template The template to use for the dialog (default: `null`). The error will be thrown when
* the template is provided together with the `content` option.
* @param {'confirm'} options.template.type The type of the template ('confirm').
* @param {string} options.template.title The title of the dialog.
* @param {string} options.template.description The description of the dialog. Default: ''.
* @param {object[]} options.template.buttons The buttons to display in the dialog. Default: [].
* @param {string} options.template.buttons.text The text of the button.
* @param {'primary' | 'secondary'} options.template.buttons.type The type of the button.
* @param {function(MouseEvent)} options.template.buttons.callback The callback to trigger when the button is clicked.
* @param {string|HTMLElement|DocumentFragment} options.content The content to display in the dialog. Can be a string, HTMLElement, or DocumentFragment. Default: ''
* @param {string} options.customClassName Custom CSS class name to apply to the dialog container. Default: ''
* @param {'solid'|'semi-transparent'} options.background Dialog background variant. Default: 'solid'.
* @param {boolean} options.contentBackground Whether to show content background. Default: false.
* @param {boolean} options.animation Whether to enable animations when showing/hiding the dialog. Default: true.
* @param {boolean} options.closable Whether the dialog can be closed by user interaction. Default: false.
* @param {object} options.a11y Object with accessibility options.
* @param {string} options.a11y.role The role of the dialog. Default: 'dialog'.
* @param {string} options.a11y.ariaLabel The label of the dialog. Default: 'Dialog'.
* @param {string} options.a11y.ariaLabelledby The ID of the element that labels the dialog. Default: ''.
* @param {string} options.a11y.ariaDescribedby The ID of the element that describes the dialog. Default: ''.
*/
update(options) {
if (!this.enabled) {
return;
}
this.updatePluginSettings(options);
const templateValue = this.getSetting('template');
if (templateValue !== Dialog.DEFAULT_SETTINGS.template && this.getSetting('content') !== Dialog.DEFAULT_SETTINGS.content) {
throw new Error('The `template` option cannot be used together with the `content` option.');
}
if (templateValue) {
_classPrivateFieldGet(_ui, this).useTemplate(templateValue.type, {
id: this.hot.guid,
...templateValue
});
} else {
_classPrivateFieldGet(_ui, this).useDefaultTemplate();
}
_classPrivateFieldGet(_ui, this).updateDialog({
isVisible: this.isVisible(),
content: this.getSetting('content'),
customClassName: this.getSetting('customClassName'),
background: this.getSetting('background'),
contentBackground: this.getSetting('contentBackground'),
animation: this.getSetting('animation'),
a11y: this.getSetting('a11y')
});
}
/**
* Displays the alert dialog with the specified content.
*
* @param {string | { title: string, description: string }} message The message to display in the dialog.
* Can be a string or an object with `title` and `description` properties.
* @param {function(MouseEvent): void} [callback] The callback to trigger when the button is clicked.
*/
showAlert(message, callback) {
const {
title = 'Alert',
description
} = isObject(message) ? message : {
title: message
};
this.show({
template: {
type: 'confirm',
title,
description,
buttons: [{
text: this.hot.getTranslatedPhrase(C.OK),
type: 'primary',
callback: function () {
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
return callback === null || callback === void 0 ? void 0 : callback(...args);
}
}]
},
contentBackground: false,
background: 'solid',
animation: true,
closable: false
});
}
/**
* Displays the confirm dialog with the specified content and options.
*
* @param {string | { title: string, description: string }} message The message to display in the dialog.
* Can be a string or an object with `title` and `description` properties.
* @param {function(MouseEvent): void} [onOk] The callback to trigger when the OK button is clicked.
* @param {function(MouseEvent): void} [onCancel] The callback to trigger when the Cancel button is clicked.
*/
showConfirm(message, onOk, onCancel) {
const {
title = 'Confirm',
description
} = isObject(message) ? message : {
title: message
};
this.show({
template: {
type: 'confirm',
title,
description,
buttons: [{
text: this.hot.getTranslatedPhrase(C.CANCEL),
type: 'secondary',
callback: function () {
for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
args[_key2] = arguments[_key2];
}
return onCancel === null || onCancel === void 0 ? void 0 : onCancel(...args);
}
}, {
text: this.hot.getTranslatedPhrase(C.OK),
type: 'primary',
callback: function () {
for (var _len3 = arguments.length, args = new Array(_len3), _key3 = 0; _key3 < _len3; _key3++) {
args[_key3] = arguments[_key3];
}
return onOk === null || onOk === void 0 ? void 0 : onOk(...args);
}
}]
},
contentBackground: true,
background: 'semi-transparent',
animation: true,
closable: false
});
}
/**
* Focus the dialog.
*/
focus() {
_classPrivateFieldGet(_ui, this).focusDialog();
}
/**
* Destroy dialog and reset plugin state.
*/
destroy() {
var _classPrivateFieldGet3;
(_classPrivateFieldGet3 = _classPrivateFieldGet(_ui, this)) === null || _classPrivateFieldGet3 === void 0 || _classPrivateFieldGet3.destroyDialog();
_classPrivateFieldSet(_ui, this, null);
_classPrivateFieldSet(_isVisible, this, false);
_classPrivateFieldSet(_selectionState, this, null);
super.destroy();
}
}
function _registerShortcuts() {
var _manager$getContext;
const manager = this.hot.getShortcutManager();
const pluginContext = (_manager$getContext = manager.getContext(SHORTCUTS_CONTEXT_NAME)) !== null && _manager$getContext !== void 0 ? _manager$getContext : manager.addContext(SHORTCUTS_CONTEXT_NAME);
pluginContext.addShortcut({
keys: [['Escape']],
callback: () => {
this.hide();
},
runOnlyIf: () => _classPrivateFieldGet(_isVisible, this) && this.getSetting('closable'),
group: SHORTCUTS_GROUP
});
pluginContext.addShortcut({
keys: [['Shift', 'Tab'], ['Tab']],
preventDefault: false,
callback: event => {
this.hot._registerTimeout(() => {
if (event.shiftKey) {
this.hot.runHooks('dialogFocusPreviousElement');
} else {
this.hot.runHooks('dialogFocusNextElement');
}
});
},
group: SHORTCUTS_GROUP
});
}
/**
* Unregister shortcuts responsible for closing the dialog and navigating through the dialog.
*/
function _unregisterShortcuts() {
const shortcutManager = this.hot.getShortcutManager();
const pluginContext = shortcutManager.getContext(SHORTCUTS_CONTEXT_NAME);
pluginContext.removeShortcutsByGroup(SHORTCUTS_GROUP);
}
/**
* Registers the focus scope for the dialog plugin.
*/
function _registerFocusScope() {
this.hot.getFocusScopeManager().registerScope(PLUGIN_KEY, _classPrivateFieldGet(_ui, this).getContainer(), {
shortcutsContextName: SHORTCUTS_CONTEXT_NAME,
type: 'modal',
runOnlyIf: () => this.isVisible(),
onActivate: focusSource => {
const isListening = this.hot.isListening();
const focusableElements = _classPrivateFieldGet(_ui, this).getFocusableElements();
if (focusableElements.length > 0) {
if (focusSource === 'tab_from_above') {
focusableElements.at(0).focus();
} else if (focusSource === 'tab_from_below') {
focusableElements.at(-1).focus();
}
} else if (focusSource !== 'tab_from_above' && focusSource !== 'tab_from_below' && isListening && !_classPrivateFieldGet(_ui, this).getContainer().contains(this.hot.rootDocument.activeElement)) {
_classPrivateFieldGet(_ui, this).getContainer().focus();
}
if (isListening) {
this.hot.runHooks('afterDialogFocus', focusSource === 'unknown' ? 'show' : focusSource);
}
}
});
}
/**
* Unregisters the focus scope for the dialog plugin.
*/
function _unregisterFocusScope() {
this.hot.getFocusScopeManager().unregisterScope(PLUGIN_KEY);
}
/**
* Called after the rendering of the table is completed. It updates the width and
* height of the dialog container to the same size as the table.
*/
function _onAfterViewRender() {
const {
view,
rootWrapperElement,
rootWindow
} = this.hot;
const width = view.isHorizontallyScrollableByWindow() ? view.getTotalTableWidth() : view.getWorkspaceWidth();
_classPrivateFieldGet(_ui, this).updateWidth(width);
const dialogInfo = rootWrapperElement.querySelector('.hot-display-license-info');
if (dialogInfo) {
const height = dialogInfo.offsetHeight;
const marginTop = Number.parseFloat(rootWindow.getComputedStyle(dialogInfo).marginTop);
_classPrivateFieldGet(_ui, this).updateHeight(height + marginTop);
}
}