@eclipse-scout/core
Version:
Eclipse Scout runtime
291 lines (250 loc) • 11.4 kB
text/typescript
/*
* Copyright (c) 2010, 2023 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {
AbstractLayout, Cell, CellEditorCancelEditKeyStroke, CellEditorCompleteEditKeyStroke, CellEditorPopupLayout, CellEditorPopupModel, CellEditorTabKeyStroke, Column, EventHandler, events, graphics, InitModelOf, KeyStroke,
KeyStrokeManagerKeyStrokeEvent, Point, Popup, Rectangle, scout, SomeRequired, Table, TableRow, TableRowOrderChangeAnimationEvent, TableRowOrderChangedEvent, ValueField, widgets
} from '../../index';
import $ from 'jquery';
export class CellEditorPopup<TValue> extends Popup implements CellEditorPopupModel<TValue> {
declare model: CellEditorPopupModel<TValue>;
declare initModel: SomeRequired<this['model'], 'parent' | 'column' | 'cell'>;
table: Table;
column: Column<TValue>;
row: TableRow;
cell: Cell<TValue>;
protected _pendingCompleteCellEdit: JQuery.Promise<void>;
protected _rowOrderChangedHandler: EventHandler<TableRowOrderChangedEvent>;
protected _keyStrokeHandler: EventHandler<KeyStrokeManagerKeyStrokeEvent>;
constructor() {
super();
this.table = null;
this.column = null;
this.row = null;
this.cell = null;
this._pendingCompleteCellEdit = null;
this._keyStrokeHandler = this._onKeyStroke.bind(this);
}
protected override _init(options: InitModelOf<this>) {
options.scrollType = options.scrollType || 'position';
super._init(options);
this.table = options.column.table;
this.link(this.cell.field);
}
protected override _createLayout(): AbstractLayout {
return new CellEditorPopupLayout(this);
}
protected override _initKeyStrokeContext() {
super._initKeyStrokeContext();
this.keyStrokeContext.registerKeyStrokes([
new CellEditorCompleteEditKeyStroke(this),
new CellEditorTabKeyStroke(this)
]);
}
protected override _createCloseKeyStroke(): KeyStroke {
return new CellEditorCancelEditKeyStroke(this);
}
protected override _open($parent: JQuery) {
this.render($parent);
this.position();
this.pack();
}
protected override _getDefaultOpen$Parent(): JQuery {
return this.table.$data;
}
protected override _render() {
super._render();
// determine CSS class for first and last column, required for additional margins/padding in cell-editor
let cssClass = '',
visibleCols = this.table.visibleColumns(),
colPos = visibleCols.indexOf(this.column);
if (colPos === 0) { // first cell
cssClass = 'first';
} else if (colPos === visibleCols.length - 1) {
cssClass = 'last';
}
this.$container
.addClass('cell-editor-popup ' + cssClass)
.data('popup', this);
let field = this.cell.field;
field.render();
field.prepareForCellEdit({
cssClass: cssClass
});
// Make sure cell content is not visible while the editor is open (especially necessary for transparent editors like checkboxes)
this.$anchor.css('visibility', 'hidden');
this._rowOrderChangedHandler = event => {
if (event.type === 'rowOrderChangeAnimation') {
// row is only set while animating
if ((event as TableRowOrderChangeAnimationEvent).row === this.row) {
this.position();
}
} else {
this.position();
}
};
this.table.on('rowOrderChanged rowOrderChangeAnimation', this._rowOrderChangedHandler);
// Set table style to focused, so that it looks as it still has the focus.
// This prevents flickering if the cell editor gets opened, especially when tabbing to the next cell editor.
if (this.table.enabled) {
this.table.$container.addClass('focused');
}
this.session.keyStrokeManager.on('keyStroke', this._keyStrokeHandler);
}
/**
* Selection border is an after element that is moved to top a little to cover the border of the previous row.
* This won't happen for the first row if there is no table header, since there is no space on top to move it up.
* In that case the selection is moved down by 1px to ensure the height of the selection always stays the same.
* If there is no border between the rows, there is no adjustment necessary, the selection is as height as the row.
* -> Position and size of the cell editor popup depends on the selection of the current row and the table style (with or without row borders)
*/
protected _alignWithSelection() {
let selectionTop = this._rowSelectionBounds().y;
if (selectionTop < 0) {
this.$container.cssMarginTop(selectionTop);
this.$container.addClass('overflow-top');
}
}
/** @internal */
_rowSelectionBounds(): Rectangle {
let bounds = new Rectangle();
let style = getComputedStyle(this.row.$row[0], ':after');
if (style) {
bounds = new Rectangle($.pxToNumber(style['left']), $.pxToNumber(style['top']), $.pxToNumber(style['width']), $.pxToNumber(style['height']));
}
return bounds;
}
protected override _postRender() {
super._postRender(); // installs the focus context for this popup
// If applicable, invoke the field's function 'onCellEditorRendered' to signal the cell-editor to be rendered.
let field = this.cell.field as ValueFieldWithCellEditorRenderedCallback<TValue>;
if (field.onCellEditorRendered) {
field.onCellEditorRendered({
openFieldPopup: this.table.openFieldPopupOnCellEdit,
cellEditorPopup: this
});
}
}
protected override _remove() {
super._remove(); // uninstalls the focus context for this popup
this.session.keyStrokeManager.off('keyStroke', this._keyStrokeHandler);
this.table.off('rowOrderChanged rowOrderChangeAnimation', this._rowOrderChangedHandler);
// table may have been removed in the meantime
if (this.table.rendered) {
this.table.$container.removeClass('focused');
}
this.$anchor.css('visibility', '');
}
override position(switchIfNecessary?: boolean) {
if (!this.rendered) {
return;
}
this._alignWithSelection();
let $cell = this.$anchor;
let cellBounds = graphics.bounds($cell);
let $row = this.row.$row;
let rowBounds = graphics.bounds($row);
let $tableData = this.table.$data;
let insetsLeft = $tableData.cssPaddingLeft() + $row.cssMarginLeft() + $row.cssBorderLeftWidth();
this.setLocation(new Point(insetsLeft + cellBounds.x, $tableData.scrollTop() + rowBounds.y));
}
/**
* @param waitForAcceptInput default is true
* @returns A promise resolved when acceptInput is performed on the editor field
*/
completeEdit(waitForAcceptInput?: boolean): JQuery.Promise<any> {
if (this._pendingCompleteCellEdit) {
// Make sure complete cell edit does not get sent twice since it will lead to exceptions. This may happen if user clicks very fast multiple times.
return this._pendingCompleteCellEdit;
}
// There is no blur event when the popup gets closed -> trigger blur so that the field may react (accept display text, close popups etc.)
// When acceptInput returns a promise, we must wait until input is accepted
// Otherwise call completeEdit immediately, also call it immediately if waitForAcceptInput is false (see _onKeyStroke)
let field = this.cell.field;
let acceptInputPromise = field.acceptInput();
if (!acceptInputPromise || !scout.nvl(waitForAcceptInput, true)) {
this._pendingCompleteCellEdit = $.resolvedPromise();
this.table.completeCellEdit();
} else {
this._pendingCompleteCellEdit = acceptInputPromise.then(() => this.table.completeCellEdit());
}
this._pendingCompleteCellEdit.then(() => {
// Ensure complete will never be called more than once
this._pendingCompleteCellEdit = $.resolvedPromise();
});
return this._pendingCompleteCellEdit;
}
isCompleteCellEditRequested(): boolean {
return !!this._pendingCompleteCellEdit;
}
cancelEdit() {
this.table.cancelCellEdit();
this.remove();
}
protected override _onMouseDownOutside(event: MouseEvent) {
let $clickedRow = $(event.target).closest('.table-row', this.table.$container[0]);
this.completeEdit();
// When the edit completes the edited row is updated and replaced with new html elements.
// When the user clicks on a cell of such a row that will be updated in order to complete the edit, the mouse down handler of the table won't be triggered.
// The mouse up handler will be triggered but does nothing because _$mouseDownRow is not set (which would be done by the mouse down handler).
// To make sure the new cell editor opens correctly we need to delegate the event to the new row that should receive the click to ensure table._onRowMouseDown is executed.
if ($clickedRow.length > 0 && !$clickedRow.isAttached()) {
this._propagateMouseDownToTableRow(event);
}
}
protected _propagateMouseDownToTableRow(event: MouseEvent) {
let doc = this.table.$container.document(true);
let clickedElement = doc.elementFromPoint(event.pageX, event.pageY) as HTMLElement;
let $target = $(clickedElement);
let $clickedRow = $target.closest('.table-row', this.table.$container[0]);
if ($clickedRow.length === 0) {
return;
}
events.propagateEvent($target[0], event);
}
protected _onKeyStroke(event: KeyStrokeManagerKeyStrokeEvent) {
if (!this._invokeCompleteEditBeforeKeyStroke(event)) {
return;
}
// Make sure completeEdit is called immediately after calling acceptInput.
// Otherwise, the keystroke will be executed before completing the edit which prevents the input from being saved
this.completeEdit(false);
}
protected _invokeCompleteEditBeforeKeyStroke(event: KeyStrokeManagerKeyStrokeEvent): boolean {
if (!this.session.keyStrokeManager.invokeAcceptInputOnActiveValueField(event.keyStroke, event.keyStrokeContext)) {
return false;
}
let $target = event.keyStrokeContext.$getScopeTarget();
if (this.$container.isOrHas($target)) {
// Don't interfere with keystrokes of the popup or children of the popup (otherwise pressing enter would close both the popup and the form at once)
return false;
}
// Not all elements created by the popup are necessarily descendants of the cell editor popup. An example
// would be a form that is opened from within the popup. To identify these kind of elements, we additionally
// check the widget hierarchy.
let target = widgets.get($target);
if (this.cell.field.isOrHas(target)) {
return false; // event target belongs to the cell editor popup -> don't close the popup
}
return true;
}
waitForCompleteCellEdit(): JQuery.Promise<void> {
if (this._pendingCompleteCellEdit) {
return this._pendingCompleteCellEdit.promise();
}
return $.resolvedPromise();
}
}
export type CellEditorRenderedOptions<TValue> = {
openFieldPopup: boolean;
cellEditorPopup: CellEditorPopup<TValue>;
};
export interface ValueFieldWithCellEditorRenderedCallback<TValue extends TModelValue, TModelValue = TValue> extends ValueField<TValue, TModelValue> {
onCellEditorRendered(options: CellEditorRenderedOptions<TValue>): void;
}