/* * Copyright (c) Jupyter Development Team. * Distributed under the terms of the Modified BSD License. */ /*----------------------------------------------------------------------------- | Copyright (c) 2014-2019, PhosphorJS Contributors | | Distributed under the terms of the BSD 3-Clause License. | | The full license is in the file LICENSE, distributed with this software. |----------------------------------------------------------------------------*/ import { IDisposable } from '@lumino/disposable'; import { Message } from '@lumino/messaging'; import { Widget } from '@lumino/widgets'; import { DataGrid } from './datagrid'; import { SelectionModel } from './selectionmodel'; import { getKeyboardLayout } from '@lumino/keyboard'; import { Signal } from '@lumino/signaling'; import { CellGroup } from './cellgroup'; /** * A response object returned from cell input validator */ export interface ICellInputValidatorResponse { /** * Flag indicating cell input is valid or not */ valid: boolean; /** * Validation error message. Set only when input is invalid */ message?: string; } /** * An object which validates cell input values. */ export interface ICellInputValidator { /** * Validate cell input. * * @param cell - The object holding cell configuration data. * * @param value - The cell value input. * * @returns An object with validation result. */ validate( cell: CellEditor.CellConfig, value: any ): ICellInputValidatorResponse; } /** * An object returned from cell editor after a successful edit. */ export interface ICellEditResponse { /** * An object which holds the configuration data for a cell. */ cell: CellEditor.CellConfig; /** * Value input. */ value: any; /** * Cursor move direction based on keys pressed to end the edit. */ cursorMovement: SelectionModel.CursorMoveDirection; } /** * An object implementing cell editing. */ export interface ICellEditor { /** * Start editing the cell. * * @param cell - The object holding cell configuration data. * * @param options - The cell editing options. */ edit(cell: CellEditor.CellConfig, options?: ICellEditOptions): void; /** * Cancel editing the cell. */ cancel(): void; } // default validation error message const DEFAULT_INVALID_INPUT_MESSAGE = 'Invalid input!'; // A type alias for available cell data types export type CellDataType = | 'string' | 'number' | 'integer' | 'boolean' | 'date' | 'string:option' | 'number:option' | 'integer:option' | 'date:option' | 'string:dynamic-option' | 'number:dynamic-option' | 'integer:dynamic-option' | 'date:dynamic-option'; /** * An object containing cell editing options. */ export interface ICellEditOptions { /** * Cell editor to use for editing. * * #### Notes * This object is only used by cell editor controller. * If not set, controller picks the most suitable editor * for the particular cell configuration. */ editor?: ICellEditor; /** * Cell input validator to use for value validation. */ validator?: ICellInputValidator; /** * Callback method to call on cell edit commit. */ onCommit?: (response: ICellEditResponse) => void; /** * Callback method to call on cell edit cancel. */ onCancel?: () => void; } /** * A cell input validator object which always returns valid. */ export class PassInputValidator implements ICellInputValidator { /** * Validate cell input. * * @param cell - The object holding cell configuration data. * * @param value - The cell value input. * * @returns An object with validation result. */ validate( cell: CellEditor.CellConfig, value: unknown ): ICellInputValidatorResponse { return { valid: true }; } } /** * Text cell input validator. */ export class TextInputValidator implements ICellInputValidator { /** * Validate cell input. * * @param cell - The object holding cell configuration data. * * @param value - The cell value input. * * @returns An object with validation result. */ validate( cell: CellEditor.CellConfig, value: string ): ICellInputValidatorResponse { if (value === null) { return { valid: true }; } if (typeof value !== 'string') { return { valid: false, message: 'Input must be valid text' }; } if (!isNaN(this.minLength) && value.length < this.minLength) { return { valid: false, message: `Text length must be greater than ${this.minLength}` }; } if (!isNaN(this.maxLength) && value.length > this.maxLength) { return { valid: false, message: `Text length must be less than ${this.maxLength}` }; } if (this.pattern && !this.pattern.test(value)) { return { valid: false, message: `Text doesn't match the required pattern` }; } return { valid: true }; } /** * Minimum text length * * The default is Number.NaN, meaning no minimum constraint */ minLength: number = Number.NaN; /** * Maximum text length * * The default is Number.NaN, meaning no maximum constraint */ maxLength: number = Number.NaN; /** * Required text pattern as regular expression * * The default is null, meaning no pattern constraint */ pattern: RegExp | null = null; } /** * Integer cell input validator. */ export class IntegerInputValidator implements ICellInputValidator { /** * Validate cell input. * * @param cell - The object holding cell configuration data. * * @param value - The cell value input. * * @returns An object with validation result. */ validate( cell: CellEditor.CellConfig, value: number ): ICellInputValidatorResponse { if (value === null) { return { valid: true }; } if (isNaN(value) || value % 1 !== 0) { return { valid: false, message: 'Input must be valid integer' }; } if (!isNaN(this.min) && value < this.min) { return { valid: false, message: `Input must be greater than ${this.min}` }; } if (!isNaN(this.max) && value > this.max) { return { valid: false, message: `Input must be less than ${this.max}` }; } return { valid: true }; } /** * Minimum value * * The default is Number.NaN, meaning no minimum constraint */ min: number = Number.NaN; /** * Maximum value * * The default is Number.NaN, meaning no maximum constraint */ max: number = Number.NaN; } /** * Real number cell input validator. */ export class NumberInputValidator implements ICellInputValidator { /** * Validate cell input. * * @param cell - The object holding cell configuration data. * * @param value - The cell value input. * * @returns An object with validation result. */ validate( cell: CellEditor.CellConfig, value: number ): ICellInputValidatorResponse { if (value === null) { return { valid: true }; } if (isNaN(value)) { return { valid: false, message: 'Input must be valid number' }; } if (!isNaN(this.min) && value < this.min) { return { valid: false, message: `Input must be greater than ${this.min}` }; } if (!isNaN(this.max) && value > this.max) { return { valid: false, message: `Input must be less than ${this.max}` }; } return { valid: true }; } /** * Minimum value * * The default is Number.NaN, meaning no minimum constraint */ min: number = Number.NaN; /** * Maximum value * * The default is Number.NaN, meaning no maximum constraint */ max: number = Number.NaN; } /** * An abstract base class that provides the most of the functionality * needed by a cell editor. All of the built-in cell editors * for various cell types are derived from this base class. Custom cell editors * can be easily implemented by extending this class. */ export abstract class CellEditor implements ICellEditor, IDisposable { /** * Construct a new cell editor. */ constructor() { this.inputChanged.connect(() => { this.validate(); }); } /** * Whether the cell editor is disposed. */ get isDisposed(): boolean { return this._disposed; } /** * Dispose of the resources held by cell editor. */ dispose(): void { if (this._disposed) { return; } if (this._gridWheelEventHandler) { this.cell.grid.node.removeEventListener( 'wheel', this._gridWheelEventHandler ); this._gridWheelEventHandler = null; } this._closeValidityNotification(); this._disposed = true; this.cell.grid.node.removeChild(this.viewportOccluder); } /** * Start editing the cell. * * @param cell - The object holding cell configuration data. * * @param options - The cell editing options. */ edit(cell: CellEditor.CellConfig, options?: ICellEditOptions): void { this.cell = cell; this.onCommit = options && options.onCommit; this.onCancel = options && options.onCancel; this.validator = options && options.validator ? options.validator : this.createValidatorBasedOnType(); this._gridWheelEventHandler = () => { this._closeValidityNotification(); this.updatePosition(); }; cell.grid.node.addEventListener('wheel', this._gridWheelEventHandler); this._addContainer(); this.updatePosition(); this.startEditing(); } /** * Cancel editing the cell. */ cancel(): void { if (this._disposed) { return; } this.dispose(); if (this.onCancel) { this.onCancel(); } } /** * Start editing the cell. Usually an editor widget is created and * added to `editorContainer` */ protected abstract startEditing(): void; /** * Return the current input entered. This method throws exceptions * if input is invalid. Error message in exception is shown as notification. */ protected abstract getInput(): any; /** * Whether the value input is valid. */ protected get validInput(): boolean { return this._validInput; } /** * Validate the cell input. Shows validation error notification when input is invalid. */ protected validate(): void { let value; try { value = this.getInput(); } catch (error) { console.log(`Input error: ${error.message}`); this.setValidity(false, error.message || DEFAULT_INVALID_INPUT_MESSAGE); return; } if (this.validator) { const result = this.validator.validate(this.cell, value); if (result.valid) { this.setValidity(true); } else { this.setValidity( false, result.message || DEFAULT_INVALID_INPUT_MESSAGE ); } } else { this.setValidity(true); } } /** * Set validity flag. * * @param valid - Whether the input is valid. * * @param message - Notification message to show. * * If message is set to empty string (which is the default) * existing notification popup is removed if any. */ protected setValidity(valid: boolean, message: string = ''): void { this._validInput = valid; this._closeValidityNotification(); if (valid) { this.editorContainer.classList.remove('lm-mod-invalid'); } else { this.editorContainer.classList.add('lm-mod-invalid'); // show a notification popup if (message !== '') { this.validityNotification = new CellEditor.Notification({ target: this.editorContainer, message: message, placement: 'bottom', timeout: 5000 }); this.validityNotification.show(); } } } /** * Create and return a cell input validator based on configuration of the * cell being edited. If no suitable validator can be found, it returns undefined. */ protected createValidatorBasedOnType(): ICellInputValidator | undefined { const cell = this.cell; const metadata = cell.grid.dataModel!.metadata( 'body', cell.row, cell.column ); switch (metadata && metadata.type) { case 'string': { const validator = new TextInputValidator(); if (typeof metadata!.format === 'string') { const format = metadata!.format; switch (format) { case 'email': validator.pattern = new RegExp( '^([a-z0-9_.-]+)@([da-z.-]+).([a-z.]{2,6})$' ); break; case 'uuid': validator.pattern = new RegExp( '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}' ); break; case 'uri': // TODO break; case 'binary': // TODO break; } } if (metadata!.constraint) { if (metadata!.constraint.minLength !== undefined) { validator.minLength = metadata!.constraint.minLength; } if (metadata!.constraint.maxLength !== undefined) { validator.maxLength = metadata!.constraint.maxLength; } if (typeof metadata!.constraint.pattern === 'string') { validator.pattern = new RegExp(metadata!.constraint.pattern); } } return validator; } break; case 'number': { const validator = new NumberInputValidator(); if (metadata!.constraint) { if (metadata!.constraint.minimum !== undefined) { validator.min = metadata!.constraint.minimum; } if (metadata!.constraint.maximum !== undefined) { validator.max = metadata!.constraint.maximum; } } return validator; } break; case 'integer': { const validator = new IntegerInputValidator(); if (metadata!.constraint) { if (metadata!.constraint.minimum !== undefined) { validator.min = metadata!.constraint.minimum; } if (metadata!.constraint.maximum !== undefined) { validator.max = metadata!.constraint.maximum; } } return validator; } break; } return undefined; } /** * Compute cell rectangle and return with other cell properties. */ protected getCellInfo(cell: CellEditor.CellConfig): Private.ICellInfo { const { grid, row, column } = cell; let data, columnX, rowY, width, height; const cellGroup = CellGroup.getGroup(grid.dataModel!, 'body', row, column); if (cellGroup) { columnX = grid.headerWidth - grid.scrollX + grid.columnOffset('body', cellGroup.c1); rowY = grid.headerHeight - grid.scrollY + grid.rowOffset('body', cellGroup.r1); width = 0; height = 0; for (let r = cellGroup.r1; r <= cellGroup.r2; r++) { height += grid.rowSize('body', r); } for (let c = cellGroup.c1; c <= cellGroup.c2; c++) { width += grid.columnSize('body', c); } data = grid.dataModel!.data('body', cellGroup.r1, cellGroup.c1); } else { columnX = grid.headerWidth - grid.scrollX + grid.columnOffset('body', column); rowY = grid.headerHeight - grid.scrollY + grid.rowOffset('body', row); width = grid.columnSize('body', column); height = grid.rowSize('body', row); data = grid.dataModel!.data('body', row, column); } return { grid: grid, row: row, column: column, data: data, x: columnX, y: rowY, width: width, height: height }; } /** * Reposition cell editor by moving viewport occluder and cell editor container. */ protected updatePosition(): void { const grid = this.cell.grid; const cellInfo = this.getCellInfo(this.cell); const headerHeight = grid.headerHeight; const headerWidth = grid.headerWidth; this.viewportOccluder.style.top = headerHeight + 'px'; this.viewportOccluder.style.left = headerWidth + 'px'; this.viewportOccluder.style.width = grid.viewportWidth - headerWidth + 'px'; this.viewportOccluder.style.height = grid.viewportHeight - headerHeight + 'px'; this.viewportOccluder.style.position = 'absolute'; this.editorContainer.style.left = cellInfo.x - 1 - headerWidth + 'px'; this.editorContainer.style.top = cellInfo.y - 1 - headerHeight + 'px'; this.editorContainer.style.width = cellInfo.width + 1 + 'px'; this.editorContainer.style.height = cellInfo.height + 1 + 'px'; this.editorContainer.style.visibility = 'visible'; this.editorContainer.style.position = 'absolute'; } /** * Commit the edited value. * * @param cursorMovement - Cursor move direction based on keys pressed to end the edit. * * @returns true on valid input, false otherwise. */ protected commit( cursorMovement: SelectionModel.CursorMoveDirection = 'none' ): boolean { this.validate(); if (!this._validInput) { return false; } let value; try { value = this.getInput(); } catch (error) { console.log(`Input error: ${error.message}`); return false; } this.dispose(); if (this.onCommit) { this.onCommit({ cell: this.cell, value: value, cursorMovement: cursorMovement }); } return true; } /** * Create container elements needed to prevent editor widget overflow * beyond viewport and to position cell editor widget. */ private _addContainer() { this.viewportOccluder = document.createElement('div'); this.viewportOccluder.className = 'lm-DataGrid-cellEditorOccluder'; this.cell.grid.node.appendChild(this.viewportOccluder); this.editorContainer = document.createElement('div'); this.editorContainer.className = 'lm-DataGrid-cellEditorContainer'; this.viewportOccluder.appendChild(this.editorContainer); // update mouse event pass-through state based on input validity this.editorContainer.addEventListener('mouseleave', (event: MouseEvent) => { this.viewportOccluder.style.pointerEvents = this._validInput ? 'none' : 'auto'; }); this.editorContainer.addEventListener('mouseenter', (event: MouseEvent) => { this.viewportOccluder.style.pointerEvents = 'none'; }); } /** * Remove validity notification popup. */ private _closeValidityNotification() { if (this.validityNotification) { this.validityNotification.close(); this.validityNotification = null; } } /** * A signal emitted when input changes. */ protected inputChanged = new Signal(this); /** * Callback method to call on cell edit commit. */ protected onCommit?: (response: ICellEditResponse) => void; /** * Callback method to call on cell edit cancel. */ protected onCancel?: () => void; /** * Cell configuration data for the cell being edited. */ protected cell: CellEditor.CellConfig; /** * Cell input validator to use for the cell being edited. */ protected validator: ICellInputValidator | undefined; /** * The div element used to prevent editor widget overflow beyond grid viewport. */ protected viewportOccluder: HTMLDivElement; /** * The div element used to contain and position editor widget. */ protected editorContainer: HTMLDivElement; /** * Notification popup used to show validation error messages. */ protected validityNotification: CellEditor.Notification | null = null; /** * Whether the cell editor is disposed. */ private _disposed = false; /** * Whether the value input is valid. */ private _validInput: boolean = true; /** * Grid wheel event handler. */ private _gridWheelEventHandler: | ((this: HTMLElement, ev: WheelEvent) => any) | null = null; } /** * Abstract base class with shared functionality * for cell editors which use HTML Input widget as editor. */ export abstract class InputCellEditor extends CellEditor { /** * Handle the DOM events for the editor. * * @param event - The DOM event sent to the editor. */ handleEvent(event: Event): void { switch (event.type) { case 'keydown': this._onKeyDown(event as KeyboardEvent); break; case 'blur': this._onBlur(event as FocusEvent); break; case 'input': this._onInput(event); break; } } /** * Dispose of the resources held by cell editor. */ dispose(): void { if (this.isDisposed) { return; } this._unbindEvents(); super.dispose(); } /** * Start editing the cell. */ protected startEditing(): void { this.createWidget(); const cell = this.cell; const cellInfo = this.getCellInfo(cell); this.input.value = this.deserialize(cellInfo.data); this.editorContainer.appendChild(this.input); this.input.focus(); this.input.select(); this.bindEvents(); } protected deserialize(value: unknown): any { if (value === null || value === undefined) { return ''; } return (value as any).toString(); } protected createWidget(): void { const input = document.createElement('input'); input.classList.add('lm-DataGrid-cellEditorWidget'); input.classList.add('lm-DataGrid-cellEditorInput'); input.spellcheck = false; input.type = this.inputType; this.input = input; } protected bindEvents(): void { this.input.addEventListener('keydown', this); this.input.addEventListener('blur', this); this.input.addEventListener('input', this); } private _unbindEvents() { this.input.removeEventListener('keydown', this); this.input.removeEventListener('blur', this); this.input.removeEventListener('input', this); } private _onKeyDown(event: KeyboardEvent) { switch (getKeyboardLayout().keyForKeydownEvent(event)) { case 'Enter': this.commit(event.shiftKey ? 'up' : 'down'); break; case 'Tab': this.commit(event.shiftKey ? 'left' : 'right'); event.stopPropagation(); event.preventDefault(); break; case 'Escape': this.cancel(); break; default: break; } } private _onBlur(event: FocusEvent) { if (this.isDisposed) { return; } if (!this.commit()) { event.preventDefault(); event.stopPropagation(); this.input.focus(); } } private _onInput(event: Event) { this.inputChanged.emit(void 0); } protected input: HTMLInputElement; protected abstract inputType: string; } /** * Cell editor for text cells. */ export class TextCellEditor extends InputCellEditor { /** * Return the current text input entered. */ protected getInput(): string | null { return this.input.value; } protected inputType: string = 'text'; } /** * Cell editor for real number cells. */ export class NumberCellEditor extends InputCellEditor { /** * Start editing the cell. */ protected startEditing(): void { super.startEditing(); this.input.step = 'any'; const cell = this.cell; const metadata = cell.grid.dataModel!.metadata( 'body', cell.row, cell.column ); const constraint = metadata.constraint; if (constraint) { if (constraint.minimum) { this.input.min = constraint.minimum; } if (constraint.maximum) { this.input.max = constraint.maximum; } } } /** * Return the current number input entered. This method throws exception * if input is invalid. */ protected getInput(): number | null { let value = this.input.value; if (value.trim() === '') { return null; } const floatValue = parseFloat(value); if (isNaN(floatValue)) { throw new Error('Invalid input'); } return floatValue; } protected inputType: string = 'number'; } /** * Cell editor for integer cells. */ export class IntegerCellEditor extends InputCellEditor { /** * Start editing the cell. */ protected startEditing(): void { super.startEditing(); this.input.step = '1'; const cell = this.cell; const metadata = cell.grid.dataModel!.metadata( 'body', cell.row, cell.column ); const constraint = metadata.constraint; if (constraint) { if (constraint.minimum) { this.input.min = constraint.minimum; } if (constraint.maximum) { this.input.max = constraint.maximum; } } } /** * Return the current integer input entered. This method throws exception * if input is invalid. */ protected getInput(): number | null { let value = this.input.value; if (value.trim() === '') { return null; } let intValue = parseInt(value); if (isNaN(intValue)) { throw new Error('Invalid input'); } return intValue; } protected inputType: string = 'number'; } /** * Cell editor for date cells. */ export class DateCellEditor extends CellEditor { /** * Handle the DOM events for the editor. * * @param event - The DOM event sent to the editor. */ handleEvent(event: Event): void { switch (event.type) { case 'keydown': this._onKeyDown(event as KeyboardEvent); break; case 'blur': this._onBlur(event as FocusEvent); break; } } /** * Dispose of the resources held by cell editor. */ dispose(): void { if (this.isDisposed) { return; } this._unbindEvents(); super.dispose(); } /** * Start editing the cell. */ protected startEditing(): void { this._createWidget(); const cell = this.cell; const cellInfo = this.getCellInfo(cell); this._input.value = this._deserialize(cellInfo.data); this.editorContainer.appendChild(this._input); this._input.focus(); this._bindEvents(); } /** * Return the current date input entered. */ protected getInput(): string | null { return this._input.value; } private _deserialize(value: any): any { if (value === null || value === undefined) { return ''; } return value.toString(); } private _createWidget() { const input = document.createElement('input'); input.type = 'date'; input.pattern = 'd{4}-d{2}-d{2}'; input.classList.add('lm-DataGrid-cellEditorWidget'); input.classList.add('lm-DataGrid-cellEditorInput'); this._input = input; } private _bindEvents() { this._input.addEventListener('keydown', this); this._input.addEventListener('blur', this); } private _unbindEvents() { this._input.removeEventListener('keydown', this); this._input.removeEventListener('blur', this); } private _onKeyDown(event: KeyboardEvent) { switch (getKeyboardLayout().keyForKeydownEvent(event)) { case 'Enter': this.commit(event.shiftKey ? 'up' : 'down'); break; case 'Tab': this.commit(event.shiftKey ? 'left' : 'right'); event.stopPropagation(); event.preventDefault(); break; case 'Escape': this.cancel(); break; default: break; } } private _onBlur(event: FocusEvent) { if (this.isDisposed) { return; } if (!this.commit()) { event.preventDefault(); event.stopPropagation(); this._input.focus(); } } private _input: HTMLInputElement; } /** * Cell editor for boolean cells. */ export class BooleanCellEditor extends CellEditor { /** * Handle the DOM events for the editor. * * @param event - The DOM event sent to the editor. */ handleEvent(event: Event): void { switch (event.type) { case 'keydown': this._onKeyDown(event as KeyboardEvent); break; case 'mousedown': // fix focus loss problem in Safari and Firefox this._input.focus(); event.stopPropagation(); event.preventDefault(); break; case 'blur': this._onBlur(event as FocusEvent); break; } } /** * Dispose of the resources held by cell editor. */ dispose(): void { if (this.isDisposed) { return; } this._unbindEvents(); super.dispose(); } /** * Start editing the cell. */ protected startEditing(): void { this._createWidget(); const cell = this.cell; const cellInfo = this.getCellInfo(cell); this._input.checked = this._deserialize(cellInfo.data); this.editorContainer.appendChild(this._input); this._input.focus(); this._bindEvents(); } /** * Return the current boolean input entered. */ protected getInput(): boolean | null { return this._input.checked; } private _deserialize(value: any): any { if (value === null || value === undefined) { return false; } return value == true; } private _createWidget() { const input = document.createElement('input'); input.classList.add('lm-DataGrid-cellEditorWidget'); input.classList.add('lm-DataGrid-cellEditorCheckbox'); input.type = 'checkbox'; input.spellcheck = false; this._input = input; } private _bindEvents() { this._input.addEventListener('keydown', this); this._input.addEventListener('mousedown', this); this._input.addEventListener('blur', this); } private _unbindEvents() { this._input.removeEventListener('keydown', this); this._input.removeEventListener('mousedown', this); this._input.removeEventListener('blur', this); } private _onKeyDown(event: KeyboardEvent) { switch (getKeyboardLayout().keyForKeydownEvent(event)) { case 'Enter': this.commit(event.shiftKey ? 'up' : 'down'); break; case 'Tab': this.commit(event.shiftKey ? 'left' : 'right'); event.stopPropagation(); event.preventDefault(); break; case 'Escape': this.cancel(); break; default: break; } } private _onBlur(event: FocusEvent) { if (this.isDisposed) { return; } if (!this.commit()) { event.preventDefault(); event.stopPropagation(); this._input.focus(); } } private _input: HTMLInputElement; } /** * Cell editor for option cells. * * It supports multiple option selection. If cell metadata contains * type attribute 'array', then it behaves as a multi select. * In that case cell data is expected to be list of string values. */ export class OptionCellEditor extends CellEditor { /** * Dispose of the resources held by cell editor. */ dispose(): void { if (this.isDisposed) { return; } super.dispose(); if (this._isMultiSelect) { document.body.removeChild(this._select); } } /** * Start editing the cell. */ protected startEditing(): void { const cell = this.cell; const cellInfo = this.getCellInfo(cell); const metadata = cell.grid.dataModel!.metadata( 'body', cell.row, cell.column ); this._isMultiSelect = metadata.type === 'array'; this._createWidget(); if (this._isMultiSelect) { this._select.multiple = true; const values = this._deserialize(cellInfo.data) as string[]; for (let i = 0; i < this._select.options.length; ++i) { const option = this._select.options.item(i); option!.selected = values.indexOf(option!.value) !== -1; } document.body.appendChild(this._select); } else { this._select.value = this._deserialize(cellInfo.data) as string; this.editorContainer.appendChild(this._select); } this._select.focus(); this._bindEvents(); this.updatePosition(); } /** * Return the current option input. */ protected getInput(): string | string[] | null { if (this._isMultiSelect) { const input: string[] = []; for (let i = 0; i < this._select.selectedOptions.length; ++i) { input.push(this._select.selectedOptions.item(i)!.value); } return input; } else { return this._select.value; } } /** * Reposition cell editor. */ protected updatePosition(): void { super.updatePosition(); if (!this._isMultiSelect) { return; } const cellInfo = this.getCellInfo(this.cell); this._select.style.position = 'absolute'; const editorContainerRect = this.editorContainer.getBoundingClientRect(); this._select.style.left = editorContainerRect.left + 'px'; this._select.style.top = editorContainerRect.top + cellInfo.height + 'px'; this._select.style.width = editorContainerRect.width + 'px'; this._select.style.maxHeight = '60px'; this.editorContainer.style.visibility = 'hidden'; } private _deserialize(value: any): string | string[] { if (value === null || value === undefined) { return ''; } if (this._isMultiSelect) { const values: string[] = []; if (Array.isArray(value)) { for (let item of value) { values.push(item.toString()); } } return values; } else { return value.toString(); } } private _createWidget() { const cell = this.cell; const metadata = cell.grid.dataModel!.metadata( 'body', cell.row, cell.column ); const items = metadata.constraint.enum; const select = document.createElement('select'); select.classList.add('lm-DataGrid-cellEditorWidget'); for (let item of items) { const option = document.createElement('option'); option.value = item; option.text = item; select.appendChild(option); } this._select = select; } private _bindEvents() { this._select.addEventListener('keydown', this._onKeyDown.bind(this)); this._select.addEventListener('blur', this._onBlur.bind(this)); } private _onKeyDown(event: KeyboardEvent) { switch (getKeyboardLayout().keyForKeydownEvent(event)) { case 'Enter': this.commit(event.shiftKey ? 'up' : 'down'); break; case 'Tab': this.commit(event.shiftKey ? 'left' : 'right'); event.stopPropagation(); event.preventDefault(); break; case 'Escape': this.cancel(); break; default: break; } } private _onBlur(event: FocusEvent) { if (this.isDisposed) { return; } if (!this.commit()) { event.preventDefault(); event.stopPropagation(); this._select.focus(); } } private _select: HTMLSelectElement; private _isMultiSelect: boolean = false; } /** * Cell editor for option cells whose value can be any value * from set of pre-defined options or values that can be input by user. */ export class DynamicOptionCellEditor extends CellEditor { /** * Handle the DOM events for the editor. * * @param event - The DOM event sent to the editor. */ handleEvent(event: Event): void { switch (event.type) { case 'keydown': this._onKeyDown(event as KeyboardEvent); break; case 'blur': this._onBlur(event as FocusEvent); break; } } /** * Dispose of the resources held by cell editor. */ dispose(): void { if (this.isDisposed) { return; } this._unbindEvents(); super.dispose(); } /** * Start editing the cell. */ protected startEditing(): void { this._createWidget(); const cell = this.cell; const cellInfo = this.getCellInfo(cell); this._input.value = this._deserialize(cellInfo.data); this.editorContainer.appendChild(this._input); this._input.focus(); this._input.select(); this._bindEvents(); } /** * Return the current option input. */ protected getInput(): string | null { return this._input.value; } private _deserialize(value: any): any { if (value === null || value === undefined) { return ''; } return value.toString(); } private _createWidget() { const cell = this.cell; const grid = cell.grid; const dataModel = grid.dataModel!; const rowCount = dataModel.rowCount('body'); const listId = 'cell-editor-list'; const list = document.createElement('datalist'); list.id = listId; const input = document.createElement('input'); input.classList.add('lm-DataGrid-cellEditorWidget'); input.classList.add('lm-DataGrid-cellEditorInput'); const valueSet = new Set(); for (let r = 0; r < rowCount; ++r) { const data = dataModel.data('body', r, cell.column); if (data) { valueSet.add(data); } } valueSet.forEach((value: string) => { const option = document.createElement('option'); option.value = value; option.text = value; list.appendChild(option); }); this.editorContainer.appendChild(list); input.setAttribute('list', listId); this._input = input; } private _bindEvents() { this._input.addEventListener('keydown', this); this._input.addEventListener('blur', this); } private _unbindEvents() { this._input.removeEventListener('keydown', this); this._input.removeEventListener('blur', this); } private _onKeyDown(event: KeyboardEvent) { switch (getKeyboardLayout().keyForKeydownEvent(event)) { case 'Enter': this.commit(event.shiftKey ? 'up' : 'down'); break; case 'Tab': this.commit(event.shiftKey ? 'left' : 'right'); event.stopPropagation(); event.preventDefault(); break; case 'Escape': this.cancel(); break; default: break; } } private _onBlur(event: FocusEvent) { if (this.isDisposed) { return; } if (!this.commit()) { event.preventDefault(); event.stopPropagation(); this._input.focus(); } } private _input: HTMLInputElement; } /** * The namespace for the `CellEditor` class statics. */ export namespace CellEditor { /** * An object which holds the configuration data for a cell. */ export type CellConfig = { /** * The grid containing the cell. */ readonly grid: DataGrid; /** * The row index of the cell. */ readonly row: number; /** * The column index of the cell. */ readonly column: number; }; /** * A widget which implements a notification popup. */ export class Notification extends Widget { /** * Construct a new notification. * * @param options - The options for initializing the notification. */ constructor(options: Notification.IOptions) { super({ node: Notification.createNode() }); this.addClass('lm-DataGrid-notification'); this.setFlag(Widget.Flag.DisallowLayout); this._target = options.target; this._message = options.message || ''; this._placement = options.placement || 'bottom'; Widget.attach(this, document.body); if (options.timeout && options.timeout > 0) { setTimeout(() => { this.close(); }, options.timeout); } } /** * Handle the DOM events for the notification. * * @param event - The DOM event sent to the notification. * * #### Notes * This method implements the DOM `EventListener` interface and is * called in response to events on the notification's DOM node. * * This should not be called directly by user code. */ handleEvent(event: Event): void { switch (event.type) { case 'mousedown': this._evtMouseDown(event as MouseEvent); break; case 'contextmenu': event.preventDefault(); event.stopPropagation(); break; } } /** * Get the placement of the notification. */ get placement(): Notification.Placement { return this._placement; } /** * Set the placement of the notification. */ set placement(value: Notification.Placement) { // Do nothing if the placement does not change. if (this._placement === value) { return; } // Update the internal placement. this._placement = value; // Schedule an update for notification. this.update(); } /** * Get the current value of the message. */ get message(): string { return this._message; } /** * Set the current value of the message. * */ set message(value: string) { // Do nothing if the value does not change. if (this._message === value) { return; } // Update the internal value. this._message = value; // Schedule an update for notification. this.update(); } /** * Get the node presenting the message. */ get messageNode(): HTMLSpanElement { return this.node.getElementsByClassName( 'lm-DataGrid-notificationMessage' )[0] as HTMLSpanElement; } /** * A method invoked on a 'before-attach' message. */ protected onBeforeAttach(msg: Message): void { this.node.addEventListener('mousedown', this); this.update(); } /** * A method invoked on an 'after-detach' message. */ protected onAfterDetach(msg: Message): void { this.node.removeEventListener('mousedown', this); } /** * A method invoked on an 'update-request' message. */ protected onUpdateRequest(msg: Message): void { const targetRect = this._target.getBoundingClientRect(); const style = this.node.style; switch (this._placement) { case 'bottom': style.left = targetRect.left + 'px'; style.top = targetRect.bottom + 'px'; break; case 'top': style.left = targetRect.left + 'px'; style.height = targetRect.top + 'px'; style.top = '0'; style.alignItems = 'flex-end'; style.justifyContent = 'flex-end'; break; case 'left': style.left = '0'; style.width = targetRect.left + 'px'; style.top = targetRect.top + 'px'; style.alignItems = 'flex-end'; style.justifyContent = 'flex-end'; break; case 'right': style.left = targetRect.right + 'px'; style.top = targetRect.top + 'px'; break; } this.messageNode.innerHTML = this._message; } /** * Handle the `'mousedown'` event for the notification. */ private _evtMouseDown(event: MouseEvent): void { // Do nothing if it's not a left mouse press. if (event.button !== 0) { return; } event.preventDefault(); event.stopPropagation(); this.close(); } private _target: HTMLElement; private _message: string = ''; private _placement: Notification.Placement; } /** * The namespace for the `Notification` class statics. */ export namespace Notification { /** * A type alias for a notification placement. */ export type Placement = 'top' | 'bottom' | 'left' | 'right'; /** * An options object for creating a notification. */ export interface IOptions { /** * Target element to attach notification to. * */ target: HTMLElement; /** * The message to show on notification. */ message?: string; /** * The placement of the notification. * * The default is `'bottom'`. */ placement?: Placement; /** * Duration in ms after which to close notification popup. * * The default is undefined, and notification is kept visible * Timeout value needs to be greater than zero */ timeout?: number; } /** * Create the DOM node for notification. */ export function createNode(): HTMLElement { const node = document.createElement('div'); const container = document.createElement('div'); container.className = 'lm-DataGrid-notificationContainer'; const message = document.createElement('span'); message.className = 'lm-DataGrid-notificationMessage'; container.appendChild(message); node.appendChild(container); return node; } } } /** * A namespace for module-private functionality. */ namespace Private { /** * A type alias for cell properties. */ export type ICellInfo = { grid: DataGrid; row: number; column: number; data: any; x: number; y: number; width: number; height: number; }; }