handsontable
Version:
Handsontable is a JavaScript Data Grid available for React, Angular and Vue.
503 lines (485 loc) • 20.1 kB
JavaScript
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);
}