UNPKG

@siberiaweb/components

Version:
1,676 lines (1,392 loc) 76.8 kB
/// <reference types="resize-observer-browser" /> import { SelectionType } from "./Types"; import Col from "./Col"; import CSS from "./CSS"; import DataCell from "./DataCell"; import DataRow from "./DataRow"; import HandlerTypes from "./HandlerTypes"; import HTMLElementUtils from "../utils/HTMLElementUtils"; import Icon from "../icon/Icon"; import LocalStorage from "@siberiaweb/local-storage/lib/LocalStorage"; import SelectCellEvent from "./SelectCellEvent"; import SelectRowEvent from "./SelectRowEvent"; import Settings from "./Settings"; import WebComponent from "@siberiaweb/webcomponent/lib/WebComponent"; import * as moment from "moment"; import "./CustomGrid.css"; /** * Настраиваемая сетка. * * @template TRecord Тип записи. */ export default class CustomGrid< TRecord > extends WebComponent { /** * Автоматическая установка фокуса после загрузки страницы. */ public static readonly ATTR_AUTOFOCUS: string = "autofocus"; /** * Автоматический выбор ячейки или строки после установки набора данных. */ public static readonly ATTR_AUTOSELECT: string = "autoselect"; /** * Отключение перемещения столбцов. */ public static readonly ATTR_COL_DRAG_DISABLED: string = "col-drag-disabled"; /** * Ручные настройки. */ public static readonly ATTR_CUSTOM_SETTINGS: string = "custom-settings"; /** * Нет ни одной записи в таблице. */ public static readonly ATTR_EMPTY: string = "empty"; /** * Признак загрузки данных. */ private static readonly ATTR_LOADING: string = "loading"; /** * Локаль. */ private static readonly ATTR_LOCALE: string = "locale"; /** * Сетка готова к выводу на экран. */ private static readonly ATTR_READY: string = "ready"; /** * Высота строки. */ private static readonly ATTR_ROW_HEIGHT: string = "row-height"; /** * Тип выбора. */ private static readonly ATTR_SELECTION_TYPE: string = "selection-type"; /** * Идентификатор для сохранения и загрузки настроек. */ private static readonly ATTR_SETTINGS_ID: string = "settings-id"; /** * Индекс последовательности перехода. */ private static readonly ATTR_TAB_INDEX: string = "tabindex"; /** * Локаль по умолчанию. */ public static readonly DEFAULT_LOCALE: string = "ru"; /** * Высота строки по умолчанию. */ public static readonly DEFAULT_ROW_HEIGHT: number = 40; /** * Минимальная высота строки. */ public static readonly MIN_ROW_HEIGHT: number = 10; /** * Тип выбора по умолчанию. */ public static readonly DEFAULT_SELECTION_TYPE: SelectionType = "cell"; /** * Высота строки. */ private _rowHeight: number = CustomGrid.DEFAULT_ROW_HEIGHT; /** * Таблица. */ private readonly table: HTMLTableElement; /** * Заголовок таблицы. */ private readonly head: HTMLTableSectionElement; /** * Основная строка, содержащая заголовки столбцов. */ private readonly headMainRow: HTMLTableRowElement; /** * Отображаемая граница при изменении ширины столбца. */ private readonly colResizeBorder: HTMLDivElement; /** * Контейнер изображения при перемещении столбца. */ private readonly colDragImage: HTMLDivElement; /** * Верхний значок, отображаемый при перемещении столбца. */ private readonly colDropIconAbove: Icon; /** * Нижний значок, отображаемый при перемещении столбца. */ private readonly colDropIconBelow: Icon; /** * Индикатор загрузки данных. */ private readonly loadIndicator: HTMLDivElement; /** * Столбцы. */ private cols: Col[] = []; /** * Изначальный порядок столбцов. */ private colsInitialOrder: Map< Col, number > = new Map(); /** * Изначально скрытые столбцы. */ private colsInitialHidden: Map< Col, boolean > = new Map(); /** * Столбцы с измененной шириной. */ private readonly resizedCols: Set< Col > = new Set(); /** * Набор данных. */ private dataSet: TRecord[] = []; /** * Выведенные записи: ( запись, строка ). */ private readonly renderedRecords: Map< TRecord, DataRow< TRecord > > = new Map(); /** * Столбец сортировки. */ protected sortCol: Col | null = null; /** * Выбранная строка. */ private selectedRow: DataRow< TRecord > | null = null; /** * Выбранная ячейка. */ private selectedCell: DataCell< TRecord > | null = null; /** * Индекс первой выведенной записи. */ private firstRenderedRecordIndex: number = 0; /** * Индекс последней выведенной записи. */ private lastRenderedRecordIndex: number = 0; /** * Обработчик вывода строки. */ public onRowRender: HandlerTypes.RowRender< TRecord > | null = null; /** * Обработчик получения значения поля. */ public onGetFieldValue: HandlerTypes.GetFieldValue< TRecord > | null = null; /** * Обработчик получения значения поля для вывода на экран. */ public onGetDisplayValue: HandlerTypes.GetDisplayValue< TRecord > | null = null; /** * Обработчик вывода ячейки. */ public onCellRender: HandlerTypes.CellRender< TRecord > | null = null; /** * Обработчик вывода значения в ячейку. */ public onCellOutputValue: HandlerTypes.CellOutputValue< TRecord > | null = null; /** * Обработчик настройки ячейки. */ public onCellCustomize: HandlerTypes.CellCustomize< TRecord > | null = null; /** * Обработчик настройки строки. */ public onRowCustomize: HandlerTypes.RowCustomize< TRecord > | null = null; /** * Обработчик проверки, что запись может быть выбрана. */ public onCheckRecordSelectable: HandlerTypes.CheckRecordSelectable< TRecord > | null = null; /** * Обработчик сохранения настроек. */ public onSaveSettings: HandlerTypes.SaveSettings | null = null; /** * Наблюдаемые атрибуты. */ public static get observedAttributes(): string[] { return WebComponent.observedAttributes.concat( [ CustomGrid.ATTR_AUTOFOCUS, CustomGrid.ATTR_COL_DRAG_DISABLED, CustomGrid.ATTR_LOADING, CustomGrid.ATTR_LOCALE, CustomGrid.ATTR_READY, CustomGrid.ATTR_ROW_HEIGHT, CustomGrid.ATTR_SELECTION_TYPE ] ); } /** * Создание таблицы. */ private createTable(): HTMLTableElement { return document.createElement( "table" ); } /** * Создание заголовка таблицы. */ private createHead(): HTMLTableSectionElement { return document.createElement( "thead" ); } /** * Создание основной строки, содержащей заголовки столбцов. */ private createHeadMainRow(): HTMLTableRowElement { return document.createElement( "tr" ); } /** * Создание отображаемой границы при изменении ширины столбца. */ private createColResizeBorder(): HTMLDivElement { let container: HTMLDivElement = document.createElement( "div" ); container.classList.add( CSS.COL_RESIZE_BORDER ); return container; } /** * Создание контейнера изображения при перемещении столбца. */ private createColDragImage(): HTMLDivElement { let container: HTMLDivElement = document.createElement( "div" ); container.classList.add( CSS.COL_DRAG_IMAGE ); return container; } /** * Создание верхнего значка, отображаемого при перемещении столбца. */ private createColDropIconAbove(): Icon { let icon: Icon = document.createElement( Icon.COMPONENT_NAME ) as Icon; icon.classList.add( CSS.COL_DROP_ICON_ABOVE ); return icon; } /** * Создание нижнего значка, отображаемого при перемещении столбца. */ private createColDropIconBelow(): Icon { let icon: Icon = document.createElement( Icon.COMPONENT_NAME ) as Icon; icon.classList.add( CSS.COL_DROP_ICON_BELOW ); return icon; } /** * Создание индикатора загрузки данных. */ private createLoadIndicator(): HTMLDivElement { let container: HTMLDivElement = document.createElement( "div" ); container.classList.add( CSS.LOAD_INDICATOR ); return container; } /** * Инициализация изменения размера столбца. * * @param col Столбец. */ private initColResize( col: Col ): void { col.getResizer().addEventListener( "dblclick", (): void => { col.setWidth( col.getDefaultWidth() ); this.resizedCols.delete( col ); this.saveSettings(); } ); const ATTR_SW_GRID_COL_RESIZING: string = "sw-grid-col-resizing"; let pageX: number = 0; let scrollLeft: number = 0; let width: number = 0; let colResizerBorderStartOffsetLeft: number = 0; let scrollListener = (): void => { this.colResizeBorder.style.left = this.colResizeBorder.offsetLeft + this.scrollLeft - scrollLeft + "px"; scrollLeft = this.scrollLeft; } let documentClickListener = ( event: MouseEvent ): void => { event.stopImmediatePropagation(); } let documentMouseMoveListener = ( event: MouseEvent ): void => { this.colResizeBorder.style.left = this.colResizeBorder.offsetLeft + event.pageX - pageX + "px"; pageX = event.pageX; } let documentMouseUpListener = (): void => { document.removeEventListener( "mousemove", documentMouseMoveListener ); document.removeEventListener( "mouseup", documentMouseUpListener ); setTimeout( () => { document.removeEventListener( "click", documentClickListener, { capture: true } ) } ); this.removeEventListener( "scroll", scrollListener ); let newWidth: number = width + this.colResizeBorder.offsetLeft - colResizerBorderStartOffsetLeft; col.setWidth( newWidth ); this.resizedCols.add( col ); this.colResizeBorder.remove(); if ( this.selectedCell !== null ) { this.scrollDataIntoView( this.selectedCell ); } else if ( this.selectedRow !== null ) { this.scrollDataIntoView( this.selectedRow ); } this.scrollLeft = scrollLeft; document.body.toggleAttribute( ATTR_SW_GRID_COL_RESIZING, false ); this.saveSettings(); } col.getResizer().addEventListener( "click", ( event: MouseEvent ): void => { event.stopImmediatePropagation(); } ); col.getResizer().addEventListener( "mousedown", ( event: MouseEvent ): void => { if ( ( event.button === 0 ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); event.stopImmediatePropagation(); document.body.toggleAttribute( ATTR_SW_GRID_COL_RESIZING, true ); pageX = event.pageX; scrollLeft = this.scrollLeft; width = col.getCell().offsetWidth; this.colResizeBorder.style.height = this.scrollHeight + "px"; this.colResizeBorder.style.left = col.getResizer().getBoundingClientRect().right - this.getBoundingClientRect().left + this.scrollLeft + "px"; this.appendChild( this.colResizeBorder ); colResizerBorderStartOffsetLeft = this.colResizeBorder.offsetLeft; document.addEventListener( "mousemove", documentMouseMoveListener ); document.addEventListener( "mouseup", documentMouseUpListener ); document.addEventListener( "click", documentClickListener, { capture: true } ); this.addEventListener( "scroll", scrollListener ); } } ); } /** * Инициализация сортировки столбца. * * @param col Столбец. */ private initColSort( col: Col ): void { col.getCell().addEventListener( "click", ( event: MouseEvent ): void => { if ( col.sortDisabled ) { return; } if ( ( event.button === 0 ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { if ( col === this.sortCol ) { col.setNextSortOrder(); } else { col.sortOrder = "asc"; } this.sort( col ); } } ); } /** * Инициализация перемещения столбца. * * @param col Столбец. */ private initColDrag( col: Col ): void { col.getCell().draggable = !this.colDragDisabled; col.getCell().addEventListener( "dragstart", ( event: DragEvent ): void => { if ( ( event.target !== col.getCell() ) || ( event.dataTransfer === null ) ) { return; } event.dataTransfer.setData( "column", col.id ); event.dataTransfer.dropEffect = "move"; event.dataTransfer.effectAllowed = "move"; this.colDragImage.innerHTML = ""; let imageWrapper: HTMLDivElement = document.createElement( "div" ); imageWrapper.appendChild( col.getCellContentTemplate().content ); this.colDragImage.appendChild( imageWrapper ); col.getCell().appendChild( this.colDragImage ); let cellRect: DOMRect = col.getCell().getBoundingClientRect(); this.colDragImage.style.height = cellRect.height + "px"; this.colDragImage.style.width = cellRect.width + "px"; let dragImageRect: DOMRect = this.colDragImage.getBoundingClientRect(); event.dataTransfer.setDragImage( this.colDragImage, dragImageRect.width / 2, dragImageRect.height / 2 ); } ); col.getCell().addEventListener( "dragover", ( event: DragEvent ): void => { if ( ( event.dataTransfer === null ) || !event.dataTransfer.types.includes( "column" ) ) { return; } event.preventDefault(); if ( !this.colDropIconAbove.isConnected ) { this.appendChild( this.colDropIconAbove ); } if ( !this.colDropIconBelow.isConnected ) { this.appendChild( this.colDropIconBelow ); } let colRect: DOMRect = col.getCell().getBoundingClientRect(); this.colDropIconAbove.style.top= colRect.top + "px"; this.colDropIconBelow.style.top = colRect.bottom + "px"; if ( event.pageX < ( colRect.left + colRect.width / 2 ) ) { this.colDropIconAbove.style.left = colRect.left + "px"; this.colDropIconBelow.style.left = colRect.left + "px"; } else { this.colDropIconAbove.style.left = colRect.right + "px"; this.colDropIconBelow.style.left = colRect.right + "px"; } } ); col.getCell().addEventListener( "dragleave", ( event: DragEvent ): void => { if ( ( event.relatedTarget instanceof Node ) && this.headMainRow.contains( event.relatedTarget ) ) { return; } this.colDropIconAbove.remove(); this.colDropIconBelow.remove(); } ); col.getCell().addEventListener( "dragend", () => { this.colDragImage.remove(); this.colDropIconAbove.remove(); this.colDropIconBelow.remove(); } ); col.getCell().addEventListener( "drop", ( event: DragEvent ): void => { this.colDragImage.remove(); this.colDropIconAbove.remove(); this.colDropIconBelow.remove(); if ( event.dataTransfer === null ) { return; } let dragCol: Col = document.getElementById( event.dataTransfer.getData( "column" ) ) as Col; if ( col === dragCol ) { return; } let colRect: DOMRect = col.getCell().getBoundingClientRect(); if ( event.pageX < ( colRect.left + colRect.width / 2 ) ) { this.moveColBefore( dragCol, col ); } else { this.moveColAfter( dragCol, col ); } } ); } /** * Инициализация наблюдателя за изменением размера. */ private initResizeObserver(): void { try { let blockSize: number = this.offsetHeight; new ResizeObserver( ( entries ): void => { if ( ( entries.length > 0 ) && ( entries[ 0 ].borderBoxSize !== undefined ) && ( entries[ 0 ].borderBoxSize.length > 0 ) ) { if ( blockSize !== entries[ 0 ].borderBoxSize[ 0 ].blockSize ) { blockSize = entries[ 0 ].borderBoxSize[ 0 ].blockSize; this.renderBody(); } } } ) .observe( this, { box: "border-box" } ); } catch ( e ) { console.warn( e ); } } /** * Инициализация хоста. */ private initCustomGridHost(): void { this.addEventListener( "scroll", (): void => { if ( this.loadIndicator.isConnected ) { this.loadIndicator.style.transform = "translate(" + this.scrollLeft + "px," + this.scrollTop + "px)"; } this.renderBody(); } ); this.addEventListener( "keydown", ( event: KeyboardEvent ): void => { if ( this.selectionType === "cell" ) { if ( ( event.key === "ArrowLeft" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); let cell = this.getNearestSelectableCell( -1, 0 ); if ( cell !== null ) { this.selectCell( cell.col, cell.record ); } } if ( ( event.key === "ArrowRight" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); let cell = this.getNearestSelectableCell( 1, 0 ); if ( cell !== null ) { this.selectCell( cell.col, cell.record ); } } if ( ( event.key === "ArrowUp" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); let cell = this.getNearestSelectableCell( 0, -1 ); if ( cell !== null ) { this.selectCell( cell.col, cell.record ); } else { this.scrollTop -= this._rowHeight; } } if ( ( event.key === "ArrowDown" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); let cell = this.getNearestSelectableCell( 0, 1 ); if ( cell !== null ) { this.selectCell( cell.col, cell.record ); } else { this.scrollTop += this._rowHeight; } } if ( ( event.key === "PageUp" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); let skipRecordCount: number = this.getMaxDisplayRowCountInViewport(); if ( skipRecordCount > 1 ) { skipRecordCount--; } let cell = this.getNearestSelectableCell( 0, -skipRecordCount ); if ( cell !== null ) { this.selectCell( cell.col, cell.record ); } else { this.scrollTop -= this.getTableBodyViewportHeight(); } } if ( ( event.key === "PageDown" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); let skipRecordCount: number = this.getMaxDisplayRowCountInViewport(); if ( skipRecordCount > 1 ) { skipRecordCount--; } let cell = this.getNearestSelectableCell( 0, skipRecordCount ); if ( cell !== null ) { this.selectCell( cell.col, cell.record ); } else { this.scrollTop += this.getTableBodyViewportHeight(); } } if ( ( event.key === "Home" ) && event.ctrlKey && !( event.altKey || event.shiftKey ) ) { event.preventDefault(); let cell = this.getNearestSelectableCell( 0, -this.dataSet.length ); if ( cell !== null ) { this.selectCell( cell.col, cell.record ); } } if ( ( event.key === "End" ) && event.ctrlKey && !( event.altKey || event.shiftKey ) ) { event.preventDefault(); let cell = this.getNearestSelectableCell( 0, this.dataSet.length ); if ( cell !== null ) { this.selectCell( cell.col, cell.record ); } } if ( ( event.key === "Home" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { let cell = this.getNearestSelectableCell( -this.getVisibleCols().length, 0 ); if ( cell !== null ) { this.selectCell( cell.col, cell.record ); } event.preventDefault(); } if ( ( event.key === "End" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); let cell = this.getNearestSelectableCell( this.getVisibleCols().length, 0 ); if ( cell !== null ) { this.selectCell( cell.col, cell.record ); } } } if ( this.selectionType === "row" ) { if ( ( event.key === "ArrowUp" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); if ( this.selectedRow !== null ) { let record: TRecord | null = this.getNearestSelectableRecord( this.selectedRow.record, -1 ); if ( record !== null ) { this.selectRow( record ); } else { this.scrollTop -= this._rowHeight; } } } if ( ( event.key === "ArrowDown" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); if ( this.selectedRow !== null ) { let record: TRecord | null = this.getNearestSelectableRecord( this.selectedRow.record, 1 ); if ( record !== null ) { this.selectRow( record ); } else { this.scrollTop += this._rowHeight; } } } if ( ( event.key === "PageUp" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); if ( this.selectedRow !== null ) { let skipRecordCount: number = this.getMaxDisplayRowCountInViewport(); if ( skipRecordCount > 1 ) { skipRecordCount--; } let record: TRecord | null = this.getNearestSelectableRecord( this.selectedRow.record, -skipRecordCount ); if ( record !== null ) { this.selectRow( record ); } else { this.scrollTop -= this.getTableBodyViewportHeight(); } } } if ( ( event.key === "PageDown" ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { event.preventDefault(); if ( this.selectedRow !== null ) { let skipRecordCount: number = this.getMaxDisplayRowCountInViewport(); if ( skipRecordCount > 1 ) { skipRecordCount--; } let record: TRecord | null = this.getNearestSelectableRecord( this.selectedRow.record, skipRecordCount ); if ( record !== null ) { this.selectRow( record ); } else { this.scrollTop += this.getTableBodyViewportHeight(); } } } if ( ( event.key === "Home" ) && !( event.altKey || event.shiftKey ) ) { event.preventDefault(); if ( this.selectedRow !== null ) { let record: TRecord | null = this.getNearestSelectableRecord( this.selectedRow.record, -this.dataSet.length ); if ( record !== null ) { this.selectRow( record ); } } } if ( ( event.key === "End" ) && !( event.altKey || event.shiftKey ) ) { event.preventDefault(); if ( this.selectedRow !== null ) { let record: TRecord | null = this.getNearestSelectableRecord( this.selectedRow.record, this.dataSet.length ); if ( record !== null ) { this.selectRow( record ); } } } } } ); } /** * Получение максимального низа заголовка таблицы. */ protected getHeadMaxBottom(): number { let maxBottom: number = 0; for ( const row of this.head.rows ) { let bottom: number = row.getBoundingClientRect().bottom; if ( bottom > maxBottom ) { maxBottom = bottom; } for ( const cell of row.cells ) { bottom = cell.getBoundingClientRect().bottom; if ( bottom > maxBottom ) { maxBottom = bottom; } } } return maxBottom; } /** * Получение высоты порта вывода для тела таблицы. */ protected getTableBodyViewportHeight(): number { let deltaY: number = this.getHeadMaxBottom() - this.getBoundingClientRect().top; if ( deltaY < 0 ) { deltaY = 0; } return this.clientHeight - deltaY; } /** * Получение максимального количества записей, которое можно отобразить в порте вывода. */ protected getMaxDisplayRowCountInViewport(): number { return Math.ceil( this.getTableBodyViewportHeight() / this._rowHeight ); } /** * Получение ячейки данных. * * @param col Столбец. * @param row Строка. */ protected getDataCell( col: Col, row: DataRow< TRecord > ): DataCell< TRecord > | null { for ( const cell of row.cells ) { if ( ( cell instanceof DataCell ) && ( cell.col === col ) ) { return cell; } } return null; } /** * @override */ protected firstConnectedCallback() { super.firstConnectedCallback(); this.classList.add( CSS.GRID ); this.toggleAttribute( CustomGrid.ATTR_EMPTY, this.dataSet.length === 0 ); if ( !this.hasAttribute( CustomGrid.ATTR_TAB_INDEX ) ) { this.tabIndex = 0; } if ( !this.customSettings ) { if ( this.settingsId ) { this.setSettings( LocalStorage.getObject( this.settingsId, {} ) ); } else { this.setSettings( {} ); } } } /** * Обработка изменения атрибута "autofocus". */ protected attrAutofocusChange(): void { if ( this.hasAttribute( CustomGrid.ATTR_AUTOFOCUS ) ) { window.requestAnimationFrame( () => { this.focus(); } ); } } /** * Обработка изменения атрибута "col-drag-disabled". */ protected attrColDragDisabledChange(): void { for ( const col of this.cols ) { col.getCell().draggable = !this.colDragDisabled; } } /** * Обработка изменения атрибута "loading". */ protected attrLoadingChange(): void { if ( this.loading ) { if ( !this.loadIndicator.isConnected ) { this.loadIndicator.style.transform = `translate( ${ this.scrollLeft }px, ${ this.scrollTop }px`; this.appendChild( this.loadIndicator ); } } else { this.loadIndicator.remove(); } } /** * Обработка изменения атрибута "locale". */ protected attrLocaleChange(): void { this.renderBody( true ); } /** * Обработка изменения атрибута "ready". */ protected attrReadyChange(): void { if ( this.ready ) { this.renderTable(); } } /** * Обработка изменения атрибута "row-height". * * @param newValue Новое значение. */ protected attrRowHeightChange( newValue: string | null ): void { let value: number = newValue === null ? CustomGrid.DEFAULT_ROW_HEIGHT : parseInt( newValue ); if ( isNaN( value ) ) { value = CustomGrid.DEFAULT_ROW_HEIGHT; } else if ( value < CustomGrid.MIN_ROW_HEIGHT ) { value = CustomGrid.MIN_ROW_HEIGHT; } this._rowHeight = value; this.renderBody( true ); } /** * Обработка изменения атрибута "selection-type". */ protected attrSelectionTypeChange(): void { this.unselectRow(); this.unselectCell(); this.renderBody( true ); } /** * @override */ protected attributeChangedCallback( name: string, oldValue: string | null, newValue: string | null ): void { super.attributeChangedCallback( name, oldValue, newValue ); switch ( name ) { case CustomGrid.ATTR_AUTOFOCUS: this.attrAutofocusChange(); break; case CustomGrid.ATTR_LOCALE: this.attrLocaleChange(); break; case CustomGrid.ATTR_LOADING: this.attrLoadingChange(); break; case CustomGrid.ATTR_COL_DRAG_DISABLED: this.attrColDragDisabledChange(); break; case CustomGrid.ATTR_READY: this.attrReadyChange(); break; case CustomGrid.ATTR_ROW_HEIGHT: this.attrRowHeightChange( newValue ); break; case CustomGrid.ATTR_SELECTION_TYPE: this.attrSelectionTypeChange(); break; } } /** * Сохранение настроек. */ protected saveSettings() { let settings: Settings = {} settings.cols = {} for ( let i: number = 0; i < this.cols.length; i++ ) { let col: Col = this.cols[ i ]; settings.cols[ col.name ] = { index: i, width: this.resizedCols.has( col ) ? col.getCell().offsetWidth : undefined, hidden: col.hidden } } if ( this.customSettings ) { if ( this.onSaveSettings !== null ) { this.onSaveSettings( settings ); } } else if ( this.settingsId ) { LocalStorage.saveObject( this.settingsId, settings ); } } /** * Получение значения поля. * * @param col Столбец. * @param record Запись. */ protected getFieldValue( col: Col, record: TRecord ): any { let value: any = null; if ( col.fieldName ) { value = ( record as any )[ col.fieldName ]; } if ( this.onGetFieldValue !== null ) { value = this.onGetFieldValue( col, record, value ); } return value; } /** * Форматирование логического значения. * * @param value Значение. * @param format Формат вывода. */ protected formatBoolean( value: boolean, format: string ): string { let displayValue: string = value ? "true" : "false"; let patterns: string[] = format.split( " " ); if ( patterns.length === 2 ) { displayValue = value ? patterns[ 0 ] : patterns[ 1 ]; } return displayValue; } /** * Форматирование даты. * * @param value Значение. * @param format Формат вывода. */ protected formatDate( value: Date, format: string ): string { return moment( value ).locale( this.locale ).format( format ); } /** * Форматирование даты, представленной в виде строки. * * @param value Значение. * @param pattern Шаблон. * @param format Формат вывода. */ protected formatDateString( value: string, pattern: string, format: string ): string { return moment( value, pattern ).locale( this.locale ).format( format ); } /** * Форматирование числа. * * @param value Значение. */ protected formatNumber( value: number ): string { return value.toLocaleString( this.locale ); } /** * Форматирование валюты. * * @param value Значение. */ protected formatCurrency( value: number ): string { return value.toLocaleString( this.locale, { minimumFractionDigits: 2 } ); } /** * Получение значения поля для вывода на экран. * * @param col Столбец. * @param value Значение. * @param record Запись. */ protected getDisplayValue( col: Col, value: any, record: TRecord ): string { let displayValue: string = value; switch ( col.fieldType ) { case "boolean": if ( col.format ) { displayValue = this.formatBoolean( value, col.format ); } break; case "date": if ( col.format ) { displayValue = this.formatDate( value, col.format ); } break; case "date-string": if ( col.pattern && col.format ) { displayValue = this.formatDateString( value, col.pattern, col.format ); } break; case "number": if ( col.format === "currency" ) { displayValue = this.formatCurrency( parseFloat( value ) ); } else if ( col.format === "locale" ) { displayValue = this.formatNumber( parseFloat( value ) ); } break; } if ( this.onGetDisplayValue !== null ) { displayValue = this.onGetDisplayValue( col, displayValue, record ); } return displayValue; } /** * Проверка, что запись может быть выбрана. * * @param record Запись. */ protected isRecordSelectable( record: TRecord ): boolean { return ( this.onCheckRecordSelectable === null ) || this.onCheckRecordSelectable( record ); } /** * Прокрутка контейнера сетки, чтобы строка или ячейка данных были в поле видимости. * * @param element Строка или ячейка. * @param options Настройка прокрутки. Опционально. */ protected scrollDataIntoView( element: HTMLTableRowElement | HTMLTableCellElement, options: ScrollIntoViewOptions = { block: "nearest", inline: "nearest", behavior: "auto" } ): void { let hostRect: DOMRect = this.getBoundingClientRect(); let headMaxBottom = this.getHeadMaxBottom(); if ( headMaxBottom < hostRect.top ) { this.style.setProperty( CSS.VAR_DATA_SCROLL_MARGIN_TOP, "0" ); } else { this.style.setProperty( CSS.VAR_DATA_SCROLL_MARGIN_TOP, ( headMaxBottom - hostRect.top ) + "px" ); } element.scrollIntoView( options ); } /** * Выбор строки. * * @param record Запись. * @param scrollIntoViewOptions Настройка прокрутки. Опционально. */ public selectRow( record: TRecord, scrollIntoViewOptions: ScrollIntoViewOptions = { block: "nearest", behavior: "auto", inline: "nearest" } ): void { if ( ( this.selectionType !== "row" ) || !this.isRecordSelectable( record ) || ( ( this.selectedRow !== null ) && ( this.selectedRow.record === record ) ) ) { return; } this.unselectRow(); if ( !this.renderedRecords.has( record ) ) { this.renderBody( true, this.dataSet.indexOf( record ) ); } let row: DataRow< TRecord > | undefined = this.renderedRecords.get( record ); if ( row === undefined ) { throw new Error( "Ошибка при выводе строк." ); } this.selectedRow = row; this.selectedRow.select(); this.scrollDataIntoView( this.selectedRow, scrollIntoViewOptions ); this.dispatchEvent( new SelectRowEvent< TRecord >( this.selectedRow ) ); } /** * Выбор ячейки. * * @param col Столбец. * @param record Запись. * @param scrollIntoViewOptions Настройка прокрутки. Опционально. */ public selectCell( col: Col, record: TRecord, scrollIntoViewOptions: ScrollIntoViewOptions = { block: "nearest", behavior: "auto", inline: "nearest" } ): void { if ( ( this.selectionType !== "cell" ) || col.hidden || !this.isRecordSelectable( record ) || ( ( this.selectedCell !== null ) && ( this.selectedCell.col === col ) && ( this.selectedCell.row.record === record ) ) ) { return; } if ( !this.renderedRecords.has( record ) ) { this.renderBody( true, this.dataSet.indexOf( record ) ); } let row: DataRow< TRecord > | undefined = this.renderedRecords.get( record ); if ( row === undefined ) { throw new Error( "Ошибка при выводе строк." ); } let cell: DataCell< TRecord > | null = this.getDataCell( col, row ); if ( cell === null ) { return; } this.unselectCell(); this.selectedCell = cell; this.selectedCell.select(); this.scrollDataIntoView( this.selectedCell, scrollIntoViewOptions ); this.dispatchEvent( new SelectCellEvent< TRecord >( this.selectedCell ) ); } /** * Отмена выбора строки. */ public unselectRow(): void { if ( this.selectedRow === null ) { return; } this.selectedRow.unselect(); this.selectedRow = null; } /** * Отмена выбора ячейки. */ public unselectCell(): void { if ( this.selectedCell === null ) { return; } this.selectedCell.unselect(); this.selectedCell = null; } /** * Создание строки данных. * * @param record Запись. */ protected createDataRow( record: TRecord ): DataRow< TRecord > { let row: DataRow< TRecord > = document.createElement( "tr", { is: DataRow.COMPONENT_NAME } ) as DataRow< TRecord > row.record = record; row.style.height = this._rowHeight + "px"; row.addEventListener( "click", ( event: MouseEvent ): void => { if ( ( event.button === 0 ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { this.selectRow( record ); } } ); return row; } /** * Создание ячейки данных. * * @param col Столбец. * @param row Строка. * @param record Запись. */ public createDataCell( col: Col, row: DataRow< TRecord >, record: TRecord, ): DataCell< TRecord > { let cell: DataCell< TRecord > = document.createElement( "td", { is: DataCell.COMPONENT_NAME } ) as DataCell< TRecord >; cell.col = col; cell.row = row; for ( const className of col.classList ) { cell.classList.add( className ); } switch ( col.contentAlign ) { case "center": cell.classList.add( CSS.CELL_CONTENT_ALIGN_CENTER ); break; case "right": cell.classList.add( CSS.CELL_CONTENT_ALIGN_RIGHT ); break; } cell.addEventListener( "click", ( event: MouseEvent ): void => { if ( ( event.button === 0 ) && !( event.altKey || event.ctrlKey || event.shiftKey ) ) { this.selectCell( col, record ); } } ); return cell; } /** * Вывод строки. * * @param row Строка. * * @returns Метод возвращает true, если выполнен пользовательский вывод и false в противном случае. */ protected rowRender( row: DataRow< TRecord > ): boolean { return ( this.onRowRender !== null ) && this.onRowRender( row ); } /** * Вывод ячейки. * * @param cell Ячейка. */ protected cellRender( cell: DataCell< TRecord > ): void { if ( this.onCellRender !== null ) { this.onCellRender( cell ); } } /** * Вывод значения в ячейку. * * @param cell Ячейка. */ protected cellOutputValue( cell: DataCell< TRecord > ): void { if ( ( this.onCellOutputValue !== null ) && this.onCellOutputValue( cell ) ) { return; } let value: any = this.getFieldValue( cell.col, cell.row.record ); cell.getContent().innerHTML = this.getDisplayValue( cell.col, value, cell.row.record ); } /** * Настройка ячейки. * * @param cell Ячейка. */ protected cellCustomize( cell: DataCell< TRecord > ): void { if ( this.onCellCustomize !== null ) { this.onCellCustomize( cell ); } } /** * Настройка строки. * * @param row Строка. */ protected rowCustomize( row: DataRow< TRecord > ): void { if ( this.onRowCustomize !== null ) { this.onRowCustomize( row ); } } /** * Вывод набора данных. * * @param startIndex Индекс записи, с которой необходимо начать вывод. * @param endIndex Индекс записи, которой необходимо закончить вывод. */ protected renderDataSet( startIndex: number, endIndex: number ): DocumentFragment { this.renderedRecords.clear(); let documentFragment: DocumentFragment = document.createDocumentFragment(); for ( let i: number = startIndex; i <= endIndex; i++ ) { let record = this.dataSet[ i ]; let row: DataRow< TRecord > = this.createDataRow( record ); if ( !this.rowRender( row ) ) { for ( const col of this.cols ) { if ( !col.hidden ) { let cell: DataCell< TRecord > = this.createDataCell( col, row, record ); this.cellRender( cell ); this.cellOutputValue( cell ); this.cellCustomize( cell ); row.appendChild( cell ); } } } this.rowCustomize( row ); documentFragment.appendChild( row ); this.renderedRecords.set( record, row ); } this.firstRenderedRecordIndex = startIndex; this.lastRenderedRecordIndex = endIndex; return documentFragment; } /** * Вывод тела таблицы. * * @param force Вывод тела таблицы, даже если требуемые для вывода записи уже выведены. Опционально. По умолчанию * false. * @param firstVisibleRecordIndex Индекс первой отображаемой записи. Опционально. По умолчанию рассчитывается * автоматически. */ protected renderBody( force: boolean = false, firstVisibleRecordIndex?: number ) { if ( firstVisibleRecordIndex === undefined ) { firstVisibleRecordIndex = Math.floor( this.scrollTop / this._rowHeight ); } let visibleRecordCount: number = Math.ceil( this.clientHeight / this._rowHeight ); let lastVisibleRecordIndex: number = firstVisibleRecordIndex + visibleRecordCount; if ( lastVisibleRecordIndex > this.dataSet.length - 1 ) { lastVisibleRecordIndex = this.dataSet.length - 1; } // Требуемые для вывода записи уже выведены? if ( !force && ( firstVisibleRecordIndex >= this.firstRenderedRecordIndex ) && ( lastVisibleRecordIndex <= this.lastRenderedRecordIndex ) ) { return; } if ( this.table.tBodies.length > 0 ) { this.table.tBodies[ 0 ].remove(); } if ( this.dataSet.length === 0 ) { return; } let renderRecordCount: number = visibleRecordCount * 3; let startRenderRecordIndex: number = firstVisibleRecordIndex - Math.ceil( renderRecordCount / 2 ); if ( startRenderRecordIndex < 0 ) { startRenderRecordIndex = 0; } let endRenderRecordIndex = lastVisibleRecordIndex + Math.ceil( renderRecordCount / 2 ); if ( endRenderRecordIndex > this.dataSet.length - 1 ) { endRenderRecordIndex = this.dataSet.length - 1; } let extraAboveHeight: number = startRenderRecordIndex * this._rowHeight; let extraBelowHeight: number = ( this.dataSet.length - 1 - endRenderRecordIndex ) * this._rowHeight; let extraRowAbove: HTMLTableRowElement = document.createElement( "tr" ); extraRowAbove.style.height = extraAboveHeight + "px"; let extraRowBelow: HTMLTableRowElement = document.createElement( "tr" ); extraRowBelow.style.height = extraBelowHeight + "px"; let body: HTMLTableSectionElement = document.createElement( "tbody" ); body.appendChild( extraRowAbove ); body.appendChild( this.renderDataSet( startRenderRecordIndex, endRenderRecordIndex ) ); body.appendChild( extraRowBelow ); this.table.appendChild( body ); if ( this.selectedRow !== null ) { let row: DataRow< TRecord > | undefined = this.renderedRecords.get( this.selectedRow.record ); if ( row !== undefined ) { this.selectedRow = row; this.selectedRow.select(); } } if ( this.selectedCell !== null ) { let row: DataRow< TRecord > | undefined = this.renderedRecords.get( this.selectedCell.row.record ); if ( row !== undefined ) { let cell: DataCell< TRecord > | null = this.getDataCell( this.selectedCell.col, row ); if ( cell !== null ) { this.selectedCell = cell; this.selectedCell.select(); } } } } /** * Вывод таблицы. */ protected renderTable(): void { if ( !this.ready ) { return; } this.headMainRow.innerHTML = ""; for ( const col of this.cols ) { if ( !col.hidden ) { this.headMainRow.appendChild( col.getCell() ); } } this.renderBody( true ); } /** * Получение ближайшей выбираемой записи. * * @param record Запись, относительно кото