UNPKG

handsontable

Version:

Handsontable is a JavaScript Data Grid available for React, Angular and Vue.

503 lines (485 loc) • 20.1 kB
import "core-js/modules/es.error.cause.js"; import "core-js/modules/es.array.push.js"; import "core-js/modules/es.array.to-sorted.js"; import "core-js/modules/esnext.iterator.constructor.js"; import "core-js/modules/esnext.iterator.every.js"; import "core-js/modules/esnext.iterator.filter.js"; import "core-js/modules/esnext.iterator.find.js"; import "core-js/modules/esnext.iterator.map.js"; import "core-js/modules/esnext.iterator.reduce.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 _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; } function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; } function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } 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 { HandsontableEditor } from "../handsontableEditor/index.mjs"; import { pivot } from "../../helpers/array.mjs"; import { isObject } from "../../helpers/object.mjs"; import { addClass, getCaretPosition, getFractionalScalingCompensation, getScrollbarWidth, getSelectionEndPosition, outerWidth, setAttribute, setCaretPosition } from "../../helpers/dom/element.mjs"; import { isDefined, stringify } from "../../helpers/mixed.mjs"; import { stripTags } from "../../helpers/string.mjs"; import { KEY_CODES, isPrintableChar } from "../../helpers/unicode.mjs"; import { textRenderer } from "../../renderers/textRenderer/index.mjs"; import { A11Y_ACTIVEDESCENDANT, A11Y_AUTOCOMPLETE, A11Y_COMBOBOX, A11Y_CONTROLS, A11Y_EXPANDED, A11Y_HASPOPUP, A11Y_LISTBOX, A11Y_LIVE, A11Y_OPTION, A11Y_POSINSET, A11Y_PRESENTATION, A11Y_RELEVANT, A11Y_SELECTED, A11Y_SETSIZE, A11Y_TEXT } from "../../helpers/a11y.mjs"; import { debounce } from "../../helpers/function.mjs"; export const EDITOR_TYPE = 'autocomplete'; /** * @private * @class AutocompleteEditor */ var _idPrefix = /*#__PURE__*/new WeakMap(); var _focusDebounced = /*#__PURE__*/new WeakMap(); var _AutocompleteEditor_brand = /*#__PURE__*/new WeakSet(); export class AutocompleteEditor extends HandsontableEditor { constructor() { super(...arguments); /** * Fix width of the internal Handsontable's instance when editor has vertical scroll. */ _classPrivateMethodInitSpec(this, _AutocompleteEditor_brand); /** * Query string to turn available values over. * * @type {string} */ _defineProperty(this, "query", null); /** * Contains stripped choices. * * @type {string[]} */ _defineProperty(this, "strippedChoices", []); /** * Contains raw choices. * * @type {Array} */ _defineProperty(this, "rawChoices", []); /** * Holds the prefix of the editor's id. * * @type {string} */ _classPrivateFieldInitSpec(this, _idPrefix, this.hot.guid.slice(0, 9)); /** * Runs focus method after debounce. */ _classPrivateFieldInitSpec(this, _focusDebounced, debounce(() => { this.focus(); }, 100)); } static get EDITOR_TYPE() { return EDITOR_TYPE; } /** * Gets current value from editable element. * * @returns {string} */ getValue() { const selectedValue = this.rawChoices.find(value => { const strippedValue = this.stripValueIfNeeded(value); return (_assertClassBrand(_AutocompleteEditor_brand, this, _isKeyValueObject).call(this, strippedValue) ? strippedValue.value : strippedValue) === this.TEXTAREA.value; }); if (isDefined(selectedValue)) { return selectedValue; } return this.TEXTAREA.value; } /** * Creates an editor's elements and adds necessary CSS classnames. */ createElements() { super.createElements(); addClass(this.htContainer, 'autocompleteEditor'); addClass(this.htContainer, this.hot.rootWindow.navigator.platform.indexOf('Mac') === -1 ? '' : 'htMacScroll'); if (this.hot.getSettings().ariaTags) { setAttribute(this.TEXTAREA, [A11Y_TEXT(), A11Y_COMBOBOX(), A11Y_HASPOPUP('listbox'), A11Y_AUTOCOMPLETE()]); } } /** * Prepares editor's metadata and configuration of the internal Handsontable's instance. * * @param {number} row The visual row index. * @param {number} col The visual column index. * @param {number|string} prop The column property (passed when datasource is an array of objects). * @param {HTMLTableCellElement} td The rendered cell element. * @param {*} value The rendered value. * @param {object} cellProperties The cell meta object (see {@link Core#getCellMeta}). */ prepare(row, col, prop, td, value, cellProperties) { super.prepare(row, col, prop, td, value, cellProperties); if (this.hot.getSettings().ariaTags) { setAttribute(this.TEXTAREA, [A11Y_EXPANDED('false'), A11Y_CONTROLS(`${_classPrivateFieldGet(_idPrefix, this)}-listbox-${row}-${col}`)]); } this.htOptions = { ...this.htOptions, valueGetter: cellValue => _assertClassBrand(_AutocompleteEditor_brand, this, _isKeyValueObject).call(this, cellValue) ? cellValue.value : cellValue }; } /** * Opens the editor and adjust its size and internal Handsontable's instance. */ open() { super.open(); const trimDropdown = this.cellProperties.trimDropdown === undefined ? true : this.cellProperties.trimDropdown; const rootInstanceAriaTagsEnabled = this.hot.getSettings().ariaTags; const sourceArray = Array.isArray(this.cellProperties.source) ? this.cellProperties.source : null; const sourceSize = sourceArray === null || sourceArray === void 0 ? void 0 : sourceArray.length; const { row: rowIndex, col: colIndex } = this; this.showEditableElement(); this.focus(); this.addHook('beforeKeyDown', event => this.onBeforeKeyDown(event)); this.htEditor.addHook('afterScroll', _classPrivateFieldGet(_focusDebounced, this)); this.htEditor.updateSettings({ colWidths: trimDropdown ? [outerWidth(this.TEXTAREA) - 2] : undefined, autoColumnSize: true, renderer: (hotInstance, TD, row, col, prop, value, cellProperties) => { textRenderer(hotInstance, TD, row, col, prop, value, cellProperties); const { filteringCaseSensitive, allowHtml, locale } = this.cellProperties; const query = this.query; let cellValue = stringify(value); let indexOfMatch; let match; if (cellValue && !allowHtml) { indexOfMatch = filteringCaseSensitive === true ? cellValue.indexOf(query) : cellValue.toLocaleLowerCase(locale).indexOf(query.toLocaleLowerCase(locale)); if (indexOfMatch !== -1) { match = cellValue.substr(indexOfMatch, query.length); cellValue = cellValue.replace(match, `<strong>${match}</strong>`); } } if (rootInstanceAriaTagsEnabled) { setAttribute(TD, [A11Y_OPTION(), // Add `setsize` and `posinset` only if the source is an array. ...(sourceArray ? [A11Y_SETSIZE(sourceSize)] : []), ...(sourceArray ? [A11Y_POSINSET(sourceArray.indexOf(value) + 1)] : []), ['id', `${this.htEditor.rootElement.id}_${row}-${col}`]]); } TD.innerHTML = cellValue; }, afterSelectionEnd: (startRow, startCol) => { if (rootInstanceAriaTagsEnabled) { const setA11yAttributes = TD => { setAttribute(TD, [A11Y_SELECTED()]); setAttribute(this.TEXTAREA, ...A11Y_ACTIVEDESCENDANT(TD.id)); }; const TD = this.htEditor.getCell(startRow, startCol, true); if (TD !== null) { setA11yAttributes(TD); } else { // If TD is null, it means that the cell is not (yet) in the viewport. // Moving the logic to after it's been scrolled to the requested cell. this.htEditor.addHookOnce('afterScrollVertically', () => { const renderedTD = this.htEditor.getCell(startRow, startCol, true); setA11yAttributes(renderedTD); }); } } } }); if (rootInstanceAriaTagsEnabled) { // Add `role=presentation` to the main table to prevent the readers from treating the option list as a table. setAttribute(this.htEditor.view._wt.wtOverlays.wtTable.TABLE, ...A11Y_PRESENTATION()); setAttribute(this.htEditor.rootElement, [A11Y_LISTBOX(), A11Y_LIVE('polite'), A11Y_RELEVANT('text'), ['id', `${_classPrivateFieldGet(_idPrefix, this)}-listbox-${rowIndex}-${colIndex}`]]); setAttribute(this.TEXTAREA, ...A11Y_EXPANDED('true')); } this.hot._registerTimeout(() => { this.queryChoices(this.TEXTAREA.value); }); } /** * Closes the editor. */ close() { this.removeHooksByKey('beforeKeyDown'); super.close(); if (this.hot.getSettings().ariaTags) { setAttribute(this.TEXTAREA, [A11Y_EXPANDED('false')]); } } /** * Verifies result of validation or closes editor if user's cancelled changes. * * @param {boolean|undefined} result If `false` and the cell using allowInvalid option, * then an editor won't be closed until validation is passed. */ discardEditor(result) { super.discardEditor(result); this.hot.view.render(); } /** * Prepares choices list based on applied argument. * * @param {string} query The query. */ queryChoices(query) { const source = this.cellProperties.source; this.query = query; if (typeof source === 'function') { source.call(this.cellProperties, query, choices => { this.rawChoices = choices; this.updateChoicesList(this.stripValuesIfNeeded(choices)); }); } else if (Array.isArray(source)) { this.rawChoices = source; this.updateChoicesList(this.stripValuesIfNeeded(source)); } else { this.updateChoicesList([]); } } /** * Updates list of the possible completions to choose. * * @private * @param {Array} choicesList The choices list to process. */ updateChoicesList(choicesList) { const pos = getCaretPosition(this.TEXTAREA); const endPos = getSelectionEndPosition(this.TEXTAREA); const sortByRelevanceSetting = this.cellProperties.sortByRelevance; const filterSetting = this.cellProperties.filter; const value = this.stripValueIfNeeded(this.getValue()); const comparableValue = _assertClassBrand(_AutocompleteEditor_brand, this, _isKeyValueObject).call(this, value) ? value.value : value; let highlightIndex = null; let choices = choicesList; if (!sortByRelevanceSetting) { choices = choices.toSorted(); } const filteredChoiceIndexes = []; const locale = this.cellProperties.locale; const filteringCaseSensitive = this.cellProperties.filteringCaseSensitive; const valueToMatch = filteringCaseSensitive ? comparableValue : comparableValue.toLocaleLowerCase(locale); for (let i = 0; i < choices.length; i++) { const currentItem = _assertClassBrand(_AutocompleteEditor_brand, this, _isKeyValueObject).call(this, choices[i]) ? stripTags(stringify(choices[i].value)) : stripTags(stringify(choices[i])); const itemToMatch = filteringCaseSensitive ? currentItem : currentItem.toLocaleLowerCase(locale); if (itemToMatch.indexOf(valueToMatch) !== -1) { filteredChoiceIndexes.push(i); if (filterSetting === false) { break; } } } if (filterSetting === false) { highlightIndex = filteredChoiceIndexes[0]; } else { choices = filteredChoiceIndexes.map(index => choices[index]); highlightIndex = choices.indexOf(valueToMatch) > -1 ? choices.indexOf(valueToMatch) : 0; } this.strippedChoices = choices; if (choices.length === 0) { this.htEditor.rootElement.style.display = 'none'; } else { this.htEditor.rootElement.style.display = ''; } this.htEditor.loadData(pivot([choices])); if (choices.length > 0) { this.updateDropdownDimensions(); this.flipDropdownVerticallyIfNeeded(); if (this.cellProperties.strict === true) { this.highlightBestMatchingChoice(highlightIndex); } } this.hot.listen(); setCaretPosition(this.TEXTAREA, pos, pos === endPos ? undefined : endPos); } /** * Calculates the space above and below the editor and flips it vertically if needed. * * @private * @returns {{ isFlipped: boolean, spaceAbove: number, spaceBelow: number}} */ flipDropdownVerticallyIfNeeded() { const result = super.flipDropdownVerticallyIfNeeded(); const { isFlipped, spaceAbove, spaceBelow } = result; this.limitDropdownIfNeeded(isFlipped ? spaceAbove : spaceBelow); return result; } /** * Checks if the internal table should generate scrollbar or could be rendered without it. * * @private * @param {number} spaceAvailable The free space as height defined in px available for dropdown list. */ limitDropdownIfNeeded(spaceAvailable) { const dropdownHeight = this.getDropdownHeight(); if (dropdownHeight > spaceAvailable) { let tempHeight = 0; let lastRowHeight = 0; let height = null; do { lastRowHeight = this.htEditor.stylesHandler.getDefaultRowHeight(); tempHeight += lastRowHeight; } while (tempHeight < spaceAvailable); height = tempHeight - lastRowHeight; if (this.isFlippedVertically) { this.htEditor.rootElement.style.top = `${parseInt(this.htEditor.rootElement.style.top, 10) + dropdownHeight - height}px`; } this.setDropdownHeight(tempHeight - lastRowHeight); } } /** * Updates width and height of the internal Handsontable's instance. * * @private */ updateDropdownDimensions() { const fractionalScalingCompensation = getFractionalScalingCompensation(); const targetWidth = this.getTargetEditorWidth() + fractionalScalingCompensation; const targetHeight = this.getTargetEditorHeight() + fractionalScalingCompensation; this.htEditor.updateSettings({ width: targetWidth, height: targetHeight }); _assertClassBrand(_AutocompleteEditor_brand, this, _fixDropdownWidth).call(this); this.htEditor.view._wt.wtTable.alignOverlaysWithTrimmingContainer(); } /** * Sets new height of the internal Handsontable's instance. * * @private * @param {number} height The new dropdown height. */ setDropdownHeight(height) { this.htEditor.updateSettings({ height }); _assertClassBrand(_AutocompleteEditor_brand, this, _fixDropdownWidth).call(this); this.htEditor.view._wt.wtTable.alignOverlaysWithTrimmingContainer(); } /** * Creates new selection on specified row index, or deselects selected cells. * * @private * @param {number|undefined} index The visual row index. */ highlightBestMatchingChoice(index) { if (typeof index === 'number') { this.htEditor.selectCell(index, 0, undefined, undefined, undefined, false); } else { this.htEditor.deselectCell(); } } /** * Calculates the proposed/target editor height that should be set once the editor is opened. * The method may be overwritten in the child class to provide a custom size logic. * * @returns {number} */ getTargetEditorHeight() { let borderCompensation = 0; if (!this.hot.getCurrentThemeName()) { const containerStyle = this.hot.rootWindow.getComputedStyle(this.htContainer.querySelector('.htCore')); borderCompensation = parseInt(containerStyle.borderTopWidth, 10) + parseInt(containerStyle.borderBottomWidth, 10); } const maxItems = Math.min(this.cellProperties.visibleRows, this.strippedChoices.length); const height = Array.from({ length: maxItems }, (_, i) => i).reduce((totalHeight, index) => { // for the first row, we need to add 1px (border-top compensation) const rowHeight = this.hot.stylesHandler.getDefaultRowHeight() + (index === 0 ? 1 : 0); return totalHeight + rowHeight; }, 0); return height + borderCompensation; } /** * Calculates the proposed/target editor width that should be set once the editor is opened. * The method may be overwritten in the child class to provide a custom size logic. * * @returns {number} */ getTargetEditorWidth() { let borderCompensation = 0; if (!this.hot.getCurrentThemeName()) { const containerStyle = this.hot.rootWindow.getComputedStyle(this.htContainer.querySelector('.htCore')); borderCompensation = parseInt(containerStyle.borderInlineStartWidth, 10) + parseInt(containerStyle.borderInlineEndWidth, 10); } return this.htEditor.getColWidth(0) + borderCompensation; } /** * Sanitizes value from potential dangerous tags. * * @private * @param {string} value The value to sanitize. * @returns {string} */ stripValueIfNeeded(value) { return this.stripValuesIfNeeded([value])[0]; } /** * Sanitizes an array of the values from potential dangerous tags. * * @private * @param {string[]} values The value to sanitize. * @returns {Array<string|{key: string, value: string}>} */ stripValuesIfNeeded(values) { const { allowHtml } = this.cellProperties; const processValue = value => stringify(allowHtml ? value : stripTags(value)); if (values.every(value => _assertClassBrand(_AutocompleteEditor_brand, this, _isKeyValueObject).call(this, value))) { return values.map(value => { return { key: processValue(value.key), value: processValue(value.value) }; }); } return values.map(value => processValue(value)); } /** * OnBeforeKeyDown callback. * * @private * @param {KeyboardEvent} event The keyboard event object. */ onBeforeKeyDown(event) { if (isPrintableChar(event.keyCode) || event.keyCode === KEY_CODES.BACKSPACE || event.keyCode === KEY_CODES.DELETE || event.keyCode === KEY_CODES.INSERT) { // for Windows 10 + FF86 there is need to add delay to make sure that the value taken from // the textarea is the freshest value. Otherwise the list of choices does not update correctly (see #7570). // On the more modern version of the FF (~ >=91) it seems that the issue is not present or it is // more difficult to induce. let timeOffset = 10; // on ctl+c / cmd+c don't update suggestion list if (event.keyCode === KEY_CODES.C && (event.ctrlKey || event.metaKey)) { return; } if (!this.isOpened()) { timeOffset += 10; } if (this.htEditor) { this.hot._registerTimeout(() => { this.queryChoices(this.TEXTAREA.value); }, timeOffset); } } } } function _fixDropdownWidth() { if (this.htEditor.view.hasVerticalScroll()) { this.htEditor.updateSettings({ width: this.getTargetEditorWidth() + getScrollbarWidth(this.hot.rootDocument) }); } } /** * Checks if the value is a key/value object. * * @param {*} value The value to check. * @returns {boolean} */ function _isKeyValueObject(value) { return isObject(value) && isDefined(value.key) && isDefined(value.value); }