ui-lib/lib/ui/grid/grid.js
2024-02-20 14:31:24 +08:00

4097 lines
142 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import '../css/grid.scss';
import { global, isPositive, isMobile, throttle, truncate, debounce } from "../../utility";
import { r as lang } from "../../utility/lgres";
import { nullOrEmpty } from "../../utility/strings";
import { createElement } from "../../functions";
import { createIcon } from "../icon";
import { createCheckbox } from "../checkbox";
import { setTooltip } from "../tooltip";
import { Popup, showAlert } from "../popup";
import { convertCssStyle } from "../extension";
import { GridColumn, GridInputColumn, GridTextColumn, GridDropdownColumn, GridCheckboxColumn, GridIconColumn, GridDateColumn } from "./column";
const ColumnChangedType = {
Reorder: 'reorder',
Resize: 'resize',
Sort: 'sort'
};
const RefreshInterval = isMobile() ? 32 : 10;
const HoverInternal = 200;
const RedumCount = 4;
const MiniDragOffset = 10;
const MiniColumnWidth = 50;
const FilterPanelWidth = 200;
const ExpandableWidth = 24;
/**
* @private
* @param {Event} e
* @returns {number}
*/
function getClientX(e) {
if (e == null) {
return null;
}
const cx = e.touches && e.touches[0]?.clientX;
return cx ?? e.clientX;
}
/**
* @private
* @param {HTMLElement} target
* @returns {number}
*/
function indexOfParent(target) {
// return [...target.parentElement.children].indexOf(target);
return Array.prototype.indexOf.call(target.parentElement.children, target);
}
const ColumnTypeDefs = {
0: GridColumn,
1: GridInputColumn,
2: GridDropdownColumn,
3: GridCheckboxColumn,
4: GridIconColumn,
5: GridTextColumn,
6: GridDateColumn
};
let r = lang;
/**
* 键值对
* @typedef ValueItem
* @property {any} Value - 值
* @property {string} DisplayValue - 显示值
* @property {boolean} [__checked] - 已选中
*/
/**
* 行数据包装接口
* @typedef GridItemWrapper
* @property {Map<string, ValueItem>} values - 真实数据对象
* @property {Map<string, GridSourceItem[]>} source - 下拉数据源缓存对象
* @property {number} __index - 行索引
* @property {number} __offset - 批量删除时暂存的索引偏移量
* @property {Map<string, boolean>} __editing - 列正在编辑
* @property {boolean} __changed - 行数据是否发生改变
* @property {boolean} __expanded - 行是否已展开
* @property {GridExpandableObject} __expandable_object - 行扩展对象
*/
/**
* 下拉框数据源接口
* @typedef GridSourceItem
* @property {string} value - 值
* @property {string} text - 显示文本
*/
/**
* 行数据可用性回调函数
* @callback GridItemBooleanCallback
* @param {Map<string, ValueItem>} item - 行数据对象
* @returns {boolean} 返回是否可用
* @this GridColumnDefinition
*/
/**
* 行数据过滤回调函数
* @callback GridItemFilterCallback
* @param {Map<string, ValueItem>} item - 行数据对象
* @param {boolean} editing - 是否处于编辑状态
* @param {HTMLElement} [body] - Grid 控件的 `<tbody>` 部分
* @returns {any} - 返回过滤后的显示或编辑值
* @this GridColumnDefinition
*/
/**
* 行数据处理回调函数
* @callback GridItemObjectCallback
* @param {Map<string, ValueItem>} item - 行数据对象
* @returns {any} 返回任意对象
* @this GridColumnDefinition
*/
/**
* 行数据字符串回调函数
* @callback GridItemStringCallback
* @param {Map<string, ValueItem>} item - 行数据对象
* @returns {string} 返回字符串
* @this GridColumnDefinition
*/
/**
* 列过滤器数据源回调函数
* @callback GridColumnFilterSourceCallback
* @param {GridColumnDefinition} col - 列定义对象
* @returns {ValueItem[]} 返回过滤器的数据数组
* @this Grid
*/
/**
* 行数据排序回调函数
* @callback GridItemSortCallback
* @param {Map<string, ValueItem>} a - 对比行数据1
* @param {Map<string, ValueItem>} b - 对比行数据2
* @returns {number} 返回大小对比结果
*/
/**
* 下拉列表数据源回调函数
* @callback GridDropdownSourceCallback
* @param {Map<string, ValueItem>} item - 行数据对象
* @returns {GridSourceItem[]} 行下拉列表数据源
*/
/**
* 下拉列表参数对象
* @typedef DropdownOptions
* @property {string} [textKey=text] - 文本关键字
* @property {string} [valueKey=value] - 值关键字
* @property {string} [htmlKey=html] - 源码显示的关键字
* @property {number} [maxLength=500] - 最大输入长度
* @property {boolean} [multiSelect] - 是否允许多选
* @property {string} [selected] - 选中值
* @property {string[]} [selectedList] - 选中的数组
* @property {boolean} [disabled] - 是否禁用
* @property {boolean} [input] - 是否支持输入
* @property {boolean} [search] - 是否支持搜索
* @property {string[]} [searchKeys] - 搜索的关键字数组
* @property {string} [searchPlaceholder] - 搜索提示文本,默认值取语言资源 `searchHolder` "Search..."
* @property {number} [tabIndex] - 焦点索引
* @property {string} [placeholder] - 输入框的提示文本
* @property {boolean} [slideFixed] - 是否固定为向下展开
* @property {HTMLElement} [wrapper] - 父元素,默认添加到头元素之后
*/
/**
* 自定义日期格式化回调函数
* @callback DateFormatterCallback
* @param {Date} date - 日期值
* @returns {any} 返回格式化后的结果
*/
/**
* 列定义接口
*
* <img src="./assets/column-types.png" alt="Column Types"/><br/>
* 代码参考页面下方的示例
* @class
* @property {string} key - 列关键字,默认以该关键字从行数据中提取单元格值,行数据的关键字属性值里包含 DisplayValue 则优先显示此值
* @property {(GridColumnTypeEnum | GridColumn)} [type=Grid.ColumnTypes.Common] - 列的类型,可以为 {@linkcode GridColumn} 的子类,或者如下内置类型 {@linkcode Grid.ColumnTypes}
* @property {number} type.Common=0 - 通用列(只读)
* @property {number} type.Input=1 - 单行文本列
* @property {number} type.Dropdown=2 - 下拉选择列
* @property {number} type.Checkbox=3 - 复选框列
* @property {number} type.Icon=4 - 图标列
* @property {number} type.Text=5 - 多行文本列
* @property {number} type.Date=6 - 日期选择列
* @property {string} [caption] - 列标题文本
* @property {any} [captionStyle] - 列标题的元素样式
* @property {number} [width] - 大于 0 则设置为该宽度,否则根据列内容自动调整列宽
* @property {("left" |"center" | "right")} [align=left] 列对齐方式
* @property {(boolean | string | GridItemBooleanCallback)} [enabled] - 列是否可用(可编辑),允许以下类型
*
* * `boolean` 则直接使用该值
* * `string` 则以该值为关键字从行数据中取值作为判断条件
* * `GridItemBooleanCallback` 则调用如下回调,以返回值作为判断条件
* @property {GridColumnDefinition} enabled.{this} - 上下文为列定义对象
* @property {Map<string, ValueItem>} enabled.item - 行数据对象
* @property {boolean} enabled.{returns} - 返回是否启用
* @property {GridItemFilterCallback} [filter] - 单元格取值采用该函数返回的值
* @property {GridColumnDefinition} filter.{this} - 上下文为列定义对象
* @property {Map<string, ValueItem>} filter.item - 行数据对象
* @property {boolean} filter.editing - 是否处于编辑状态
* @property {HTMLElement} [filter.body] - Grid 控件的 `<tbody>` 部分
* @property {any} filter.{returns} - 返回过滤后的显示或编辑值
* @property {string} [text] - 单元格以该值填充内容忽略filter与关键字属性
* @property {boolean} [visible=true] - 列是否可见
* @property {boolean} [resizable=true] - 列是否允许调整宽度
* @property {boolean} [sortable=true] - 列是否允许排序
* @property {boolean} [orderable=true] - 列是否允许重排顺序
* @property {boolean} [allcheck=false] - 列为复选框类型时是否在列头增加全选复选框
* @property {boolean} [shrink=false] - 列为收缩列,禁用自动调整大小
* @property {string} [class] - 单元格元素的额外样式类型字符串(仅在重建行元素时读取)
* @property {any} [css] - 单元格css样式对象仅在重建行元素时读取
* @property {any} [totalCss] - 合计行样式(仅在重建合计行元素时读取)
* @property {(any | GridItemObjectCallback)} [style] - 单元格样式(填充行列数据时读取),支持直接返回样式对象或调用函数返回(若赋值则忽略 [styleFilter]{@linkcode GridColumnDefinition#styleFilter}
* @property {GridColumnDefinition} style.{this} - 上下文为列定义对象
* @property {Map<string, ValueItem>} style.item - 行数据对象
* @property {any} style.{returns} - 返回样式对象
* @property {(@deprecated)} [styleFilter] - **已过时**<br/>_根据返回值填充单元格样式填充行列数据时读取_
* @property {(string | GridItemStringCallback)} [background] - 设置单元格背景色(填充行列数据时读取),支持直接设置颜色字符串或调用函数返回(若赋值则忽略 [bgFilter]{@linkcode GridColumnDefinition#bgFilter}
* @property {GridColumnDefinition} background.{this} - 上下文为列定义对象
* @property {Map<string, ValueItem>} background.item - 行数据对象
* @property {string} background.{returns} - 返回单元格背景色字符串
* @property {(@deprecated)} [bgFilter] - **已过时**<br/>_根据返回值设置单元格背景色_
* @property {Map<string, Function>} [events] - 给单元格元素附加事件(事件函数上下文为数据行对象)
* @property {Function} events.{event} - 事件回调函数
* @property {Map<string, ValueItem>} events.{event}.{this} - 上下文为行数据对象
* @property {Event} events.{event}.e - 事件参数
* @property {(any | GridItemObjectCallback)} [attrs] - 根据返回值设置单元格元素的附加属性,允许直接设置对象也支持调用如下函数返回对象
* @property {GridColumnDefinition} attrs.{this} - 上下文为列定义对象
* @property {Map<string, ValueItem>} attrs.item - 行数据对象
* @property {any} attrs.{returns} - 返回附加属性对象
* @property {boolean} [allowFilter=false] - 是否允许进行列头过滤
* @property {any[]} [filterValues] - 过滤值的数组
* @property {boolean} [filterAllowNull=false] - 是否区分 `null` 与空字符串
* @property {(ValueItem[] | GridColumnFilterSourceCallback)} [filterSource] - 自定义列过滤器的数据源,允许调用如下函数
* @property {Grid} filterSource.{this} - 上下文为 Grid
* @property {GridColumnDefinition} filterSource.col - 列定义对象
* @property {ValueItem[]} filterSource.{returns} - 返回过滤器的数据数组
* @property {GridItemSortCallback} [sortFilter] - 自定义列排序函数
* @property {Map<string, ValueItem>} sortFilter.a - 对比行数据1
* @property {Map<string, ValueItem>} sortFilter.b - 对比行数据2
* @property {number} sortFilter.{returns} - 返回大小对比结果
* @property {DropdownOptions} [dropOptions] - 列为下拉列表类型时以该值设置下拉框的参数
* @property {(GridSourceItem[] | Promise<GridSourceItem[]> | GridDropdownSourceCallback)} [source] - 列为下拉列表类型时以该值设置下拉列表数据源,支持返回异步对象,也支持如下函数返回
* @property {Map<string, ValueItem>} source.item - 行数据对象
* @property {GridSourceItem[]} source.{returns} - 返回行下拉列表数据源
* @property {boolean} [sourceCache=false] - 下拉列表数据源是否缓存结果即行数据未发生变化时仅从source属性获取一次值
* @property {("fa-light" | "fa-regular" | "fa-solid")} [iconType=fa-light] - 列为图标类型时以该值设置图标样式
* @property {string} [dateMin] - 列为日期类型时以该值作为最小可选日期值
* @property {string} [dateMax] - 列为日期类型时以该值作为最大可选日期值
* @property {DateFormatterCallback} [dateValueFormatter] - 列为日期类型时自定义日期格式化函数
* @property {Date} dateValueFormatter.date - 日期值
* @property {any} dateValueFormatter.{returns} - 返回格式化后的结果
* @property {(string | GridItemStringCallback)} [tooltip] - 额外设置单元格的 tooltip支持直接使用字符串或者调用如下函数
* @property {GridColumnDefinition} tooltip.{this} - 上下文为列定义对象
* @property {Map<string, ValueItem>} tooltip.item - 行数据对象
* @property {string} tooltip.{returns} - 返回额外 tooltip 字符串
* @example
* [
* {
* key: 'name',
* // type: Grid.ColumnTypes.Common,
* caption: 'Name',
* captionStyle: {
* 'font-style': 'italic'
* },
* width: 150,
* allowFilter: true
* },
* {
* key: 'birthday',
* type: Grid.ColumnTypes.Date,
* caption: 'Birthday',
* width: 120,
* dateMin: '1900-01-01',
* dateMax: '2025-01-01',
* dateValueFormatter: toDateValue
* },
* {
* key: 'age',
* type: Grid.ColumnTypes.Input,
* caption: 'Age',
* enabled: false,
* align: 'right',
* filter: item => {
* const ms = new Date() - new Date(item.birthday);
* const age = Math.floor(ms / 1000 / 60 / 60 / 24 / 365);
* return String(age);
* }
* },
* {
* key: 'sex',
* type: Grid.ColumnTypes.Dropdown,
* caption: 'Sex',
* source: [
* { value: 'male', text: 'Male' },
* { value: 'female', text: 'Female' },
* { value: 'other', text: 'Other' }
* ]
* },
* {
* key: 'active',
* type: Grid.ColumnTypes.Checkbox,
* caption: 'Active'
* },
* {
* key: 'remove',
* type: Grid.ColumnTypes.Icon,
* text: 'times',
* resizable: false,
* sortable: false,
* orderable: false,
* tooltip: 'Remove',
* events: {
* onclick: () => {
* showConfirm('Remove', 'Are you sure you want to remove this person?', [
* {
* key: 'yes',
* text: 'Yes',
* trigger: () => {
* console.log('yes');
* return true;
* }
* },
* {
* key: 'no',
* text: 'No'
* }
* ], 'question')
* }
* }
* }
* ]
*/
class GridColumnDefinition {
/**
* 列关键字,默认以该关键字从行数据中提取单元格值,行数据的关键字属性值里包含 DisplayValue 则优先显示此值
* @type {string}
* @ignore
*/
key;
/**
* 列的类型,可以为 {@linkcode GridColumn} 的子类,或者内置类型 {@linkcode Grid.ColumnTypes}
* @type {(GridColumnTypeEnum | GridColumn)}
* @default Grid.ColumnTypes.Common
* @ignore
*/
type;
/**
* 列标题文本
* @type {string}
* @ignore
*/
caption;
/**
* 列标题的元素样式
* @type {any}
* @ignore
*/
captionStyle;
/**
* 大于 0 则设置为该宽度,否则根据列内容自动调整列宽
* @type {number}
* @ignore
*/
width;
/**
* 列对齐方式
* @type {("left" |"center" | "right")}
* @default "left"
* @ignore
*/
align;
/**
* 列是否可用(可编辑),允许以下类型
*
* * `boolean` 则直接使用该值
* * `string` 则以该值为关键字从行数据中取值作为判断条件
* * `GridItemBooleanCallback` 则调用如下回调,以返回值作为判断条件
* @type {(boolean | string | GridItemBooleanCallback)}
* @ignore
*/
enabled;
/**
* 单元格取值采用该函数返回的值
* @type {GridItemFilterCallback}
* @ignore
*/
filter;
/**
* 单元格以该值填充内容忽略filter与关键字属性
* @type {string}
* @ignore
*/
text;
/**
* 列是否可见
* @type {boolean}
* @default true
* @ignore
*/
visible;
/**
* 列是否允许调整宽度
* @type {boolean}
* @default true
* @ignore
*/
resizable;
/**
* 列是否允许排序
* @type {boolean}
* @default true
* @ignore
*/
sortable;
/**
* 列是否允许重排顺序
* @type {boolean}
* @default true
* @ignore
*/
orderable;
/**
* 列为复选框类型时是否在列头增加全选复选框
* @type {boolean}
* @default false
* @ignore
*/
allcheck;
/**
* 列为收缩列,禁用自动调整大小
* @type {boolean}
* @default false
* @ignore
*/
shrink;
/**
* 单元格元素的额外样式类型字符串(仅在重建行元素时读取)
* @type {string}
* @ignore
*/
class;
/**
* 单元格css样式对象仅在重建行元素时读取
* @type {any}
* @ignore
*/
css;
/**
* 合计行样式(仅在重建合计行元素时读取)
* @type {any}
* @ignore
*/
totalCss;
/**
* 单元格样式(填充行列数据时读取),支持直接返回样式对象或调用函数返回(若赋值则忽略 [styleFilter]{@linkcode GridColumnDefinition#styleFilter}
* @type {(any | GridItemObjectCallback)}
* @ignore
*/
style;
/**
* **已过时**<br/>_根据返回值填充单元格样式填充行列数据时读取_
* @type {GridItemObjectCallback}
* @ignore
* @deprecated
*/
styleFilter;
/**
* 设置单元格背景色(填充行列数据时读取),支持直接设置颜色字符串或调用函数返回(若赋值则忽略 [bgFilter]{@linkcode GridColumnDefinition#bgFilter}
* @type {(string | GridItemStringCallback)}
* @ignore
*/
background;
/**
* **已过时**<br/>_根据返回值设置单元格背景色_
* @type {GridItemStringCallback}
* @ignore
* @deprecated
*/
bgFilter;
/**
* 给单元格元素附加事件(事件函数上下文为数据行对象)
* @type {Map<string, Function>}
* @ignore
*/
events;
/**
* 根据返回值设置单元格元素的附加属性,允许直接设置对象也支持调用如下函数返回对象
* @type {(any | GridItemObjectCallback)}
* @ignore
*/
attrs;
/**
* 是否允许进行列头过滤
* @type {boolean}
* @default false
* @ignore
*/
allowFilter;
/**
* 过滤值的数组
* @type {any[]}
* @ignore
*/
filterValues;
/**
* 是否区分 `null` 与空字符串
* @type {boolean}
* @default false
* @ignore
*/
filterAllowNull;
/**
* 自定义列过滤器的数据源,允许调用如下函数
* @type {(ValueItem[] | GridColumnFilterSourceCallback)}
* @ignore
*/
filterSource;
/**
* 自定义列排序函数
* @type {GridItemSortCallback}
* @ignore
*/
sortFilter;
/**
* 列为下拉列表类型时以该值设置下拉框的参数
* @type {DropdownOptions}
* @ignore
*/
dropOptions;
/**
* 列为下拉列表类型时以该值设置下拉列表数据源,支持返回异步对象,也支持如下函数返回
* @type {(GridSourceItem[] | Promise<GridSourceItem[]> | GridDropdownSourceCallback)}
* @ignore
*/
source;
/**
* 下拉列表数据源是否缓存结果即行数据未发生变化时仅从source属性获取一次值
* @type {boolean}
* @default false
* @ignore
*/
sourceCache;
/**
* 列为图标类型时以该值设置图标样式
* @type {("fa-light" | "fa-regular" | "fa-solid")}
* @default "fa-light"
* @ignore
*/
iconType;
/**
* 列为日期类型时以该值作为最小可选日期值
* @type {string}
* @ignore
*/
dateMin;
/**
* 列为日期类型时以该值作为最大可选日期值
* @type {string}
* @ignore
*/
dateMax;
/**
* 列为日期类型时自定义日期格式化函数
* @type {DateFormatterCallback}
* @ignore
*/
dateValueFormatter;
/**
* 额外设置单元格的 tooltip支持直接使用字符串或者调用如下函数
* @type {(string | GridItemStringCallback)}
* @ignore
*/
tooltip;
/**
* 列头复选框改变时触发
* @type {Function}
* @event
* @param {GridColumnDefinition} col - 列定义对象
* @param {boolean} flag - 是否选中
* @this Grid
*/
onAllChecked;
/**
* 单元格发生变化时触发
* @event
* @param {Map<string, ValueItem>} item - 行数据对象
* @param {(boolean | string | number)} value - 修改后的值
* @param {(boolean | string | number)} oldValue - 修改前的值
* @param {any} [e] - 列修改事件传递过来的任意对象
* @this Grid
*/
onChanged;
/**
* 文本单元格在输入完成时触发的事件
* @event
* @param {GridColumnDefinition} col - 列定义对象
* @param {string} value - 修改后的文本框值
* @this Grid
*/
onInputEnded;
/**
* 列过滤点击 `OK` 时触发的事件
* @event
* @param {GridColumnDefinition} col - 列定义对象
* @param {ValueItem[]} selected - 选中的过滤项
* @this Grid
*/
onFilterOk;
/**
* 列过滤后触发的事件
* @event
* @param {GridColumnDefinition} col - 列定义对象
* @this Grid
*/
onFiltered;
/**
* 列为下拉框类型时在下拉列表展开时触发的事件
* @event
* @param {Map<string, ValueItem>} item - 行数据对象
* @param {Dropdown} drop - 下拉框对象
* @this GridColumnDefinition
*/
onDropExpanded;
}
/**
* 判断复选框列的回调函数
* @callback ColumnTypesEnumIsCheckbox
* @param {number} type - 列类型
* @returns {boolean} 返回是否为复选框列
*/
/**
* 列类型枚举
* @enum {number}
*/
const GridColumnTypeEnum = {
/** 0 - 通用列(只读) */
Common: 0,
/** 1 - 单行文本列 */
Input: 1,
/** 2 - 下拉选择列 */
Dropdown: 2,
/** 3 - 复选框列 */
Checkbox: 3,
/** 4 - 图标列 */
Icon: 4,
/** 5 - 多行文本列 */
Text: 5,
/** 6 - 日期选择列 */
Date: 6,
/**
* 判断列是否为复选框列
* @type {ColumnTypesEnumIsCheckbox}
*/
isCheckbox(type) { return type === 3 }
};
/**
* 列排序枚举
* @enum {number}
*/
const GridColumnDirection = {
/** -1 - 倒序 */
Descending: -1,
/** 1 - 升序 */
Ascending: 1
};
/**
* 列排序定义接口
* @typedef GridColumnSortDefinition
* @property {string} column - 排序列的关键字
* @property {("asc" | "desc")} order - 升序或降序
*/
/**
* 扩展行对象接口
* @typedef GridExpandableObject
* @property {HTMLElement} element - 扩展行元素
*/
/**
* 扩展行生成回调函数
* @callback GridExpandableObjectCallback
* @param {Map<string, ValueItem>} item - 行数据对象
* @returns {GridExpandableObject} 返回扩展行对象
* @this Grid
*/
/**
* @typedef GridColumnAttr
* @property {boolean} dragging - 列正在拖拽
* @property {number} offset - 列拖拽偏移
* @property {Function} mousemove - 拖拽或调整大小移动回调函数
* @property {Function} mouseup - 拖拽或调整大小鼠标释放回调函数
* @property {number} resizing - 列临时大小
* @property {boolean} sizing - 列已进入修改大小的状态
* @property {boolean} autoResize - 列需要自动调整大小
* @property {any} style - 列样式对象
* @property {ValueItem[]} filterSource - 列过滤面板数据源
* @property {number} filterHeight - 列过滤面板高度
* @property {number} filterTop - 列过滤面板滚动头部间距
* @private
*/
/**
* Grid 控件基础类
*
* <img src="./assets/grid-sample.png" alt="Grid Sample"/><br/>
* 函数调用流程图<br/>
* <img src="./assets/grid.jpg" alt="Grid"/>
* @class
* @property {GridColumnDefinition[]} columns - 列定义的数组
* @property {Map<string, ValueItem>} [total] - 合计行数据
* @property {any} [langs] - 多语言资源对象
* @property {string} [langs.all=( All )]
* @property {string} [langs.ok=OK]
* @property {string} [langs.reset=Reset]
* @property {string} [langs.cancel=Cancel]
* @property {string} [langs.null=( Null )]
* @property {string} [langs.addLevel=Add Level]
* @property {string} [langs.deleteLevel=Delete Level]
* @property {string} [langs.copyLevel=Copy Level]
* @property {string} [langs.asc=Ascending]
* @property {string} [langs.desc=Descending]
* @property {string} [langs.column=Column]
* @property {string} [langs.order=Order]
* @property {string} [langs.sort=Sort]
* @property {string} [langs.requirePrompt=All sort criteria must have...]
* @property {string} [langs.duplicatePrompt={column} is being sorted more than once...]
* @property {number} [virtualCount=100] - 行数大于等于该值则启用虚模式
* @property {number} [rowHeight=36] - 表格行高
* @property {number} [lineHeight=24] - 文本行高(多行文本列计算高度时使用)
* @property {string} [filterIcon=filter] - 列头过滤图标,旧版本样式(横着三个点)需修改为 `"ellipsis-h"`
* @property {number} [extraRows=0] - 列表底部留出额外的空白行
* @property {number} [filterRowHeight=30] - 过滤条件列表的行高
* @property {number} [height] - 列表高度值,为 0 时列表始终显示全部内容(自增高),为非数字或者小于 0 则根据容器高度来确定虚模式的渲染行数
* @property {boolean} [multiSelect=false] - 是否允许多选
* @property {boolean} [fullrowClick=true] - 为 `false` 时只有点击在单元格内才会选中行
* @property {boolean} [tooltipDisabled=false] - 单元格 tooltip 是否禁用
* @property {boolean} [headerVisible=true] - 列头是否显示
* @property {(Window | HTMLElement)} [window=global] - 监听事件的窗口载体
* @property {number} [sortIndex=-1] - 排序列的索引
* @property {GridColumnDirection} [sortDirection=GridColumnDirection.Ascending] - 排序方式,正数升序,负数倒序
* @property {GridColumnSortDefinition[]} [sortArray] - 排序列数组
* @property {boolean} [expandable=false] - 是否支持点击扩展
* @property {GridExpandableObjectCallback} [expandableGenerator] - 扩展行生成器
* @property {Grid} expandableGenerator.{this} - 上下文为 Grid
* @property {Map<string, ValueItem>} expandableGenerator.item - 行数据对象
* @property {GridExpandableObject} expandableGenerator.{returns} - 返回扩展行对象
* @example <caption>基础示例</caption>
* <div id="grid"></div>
* @example
* #grid>.ui-grid {
* width: 600px;
* height: 400px;
* }
* @example
* const grid = new Grid('#grid', GetTextByKey);
* grid.columns = [
* {
* key: 'name',
* caption: 'Name',
* width: 140,
* allowFilter: true
* },
* {
* key: 'age',
* caption: 'Age',
* type: Grid.ColumnTypes.Input,
* width: 80
* },
* {
* key: 'study',
* caption: 'Study',
* type: Grid.ColumnTypes.Dropdown,
* width: 120,
* source: [
* { value: 'a', text: 'A' },
* { value: 'b', text: 'B' },
* { value: 'c', text: 'C' }
* ]
* }
* ];
* grid.multiSelect = true;
* grid.init();
* grid.source = [
* { name: '张三', age: '19', study: 'a' },
* { name: '李四', age: '24', study: 'a' },
* { name: '王五', age: '20', study: 'c' }
* ];
*/
export class Grid {
/**
* 内部引用变量
* @private
*/
_var = {
/**
* 父容器元素
* @type {HTMLElement}
* @private
*/
parent: null,
/**
* Grid 元素 - `div.ui-grid`
* @type {HTMLDivElement}
* @private
*/
el: null,
/**
* 全部数据数组
* @type {GridItemWrapper[]}
* @private
*/
source: null,
/**
* 当前已过滤显示的数据数组
* @type {GridItemWrapper[]}
* @private
*/
currentSource: null,
/**
* Grid 是否只读
* @type {boolean}
* @private
*/
readonly: false,
/**
* 当前选中的列索引
* @type {number}
* @private
*/
selectedColumnIndex: -1,
/**
* 当前选中的行索引数组
* @type {number[]}
* @private
*/
selectedIndexes: null,
/**
* 虚模式头部索引
* @type {number}
* @private
*/
startIndex: 0,
/**
* 旧选择索引数组
* @type {number[]}
* @private
*/
oldSelectedIndexes: null,
/**
* 旧虚模式头部索引
* @type {number}
* @private
*/
oldIndex: null,
/**
* 当前滚动上边距
* @type {number}
* @private
*/
scrollTop: 0,
/**
* 当前滚动左边距
* @type {number}
* @private
*/
scrollLeft: 0,
/**
* 浏览器是否为 Firefox
* @type {boolean}
* @private
*/
isFirefox: false,
/**
* 一页高度可显示的行数
* @type {number}
* @private
*/
rowCount: -1,
/**
* 列类型缓存字典
* @type {Map<string, GridColumn>}
* @private
*/
colTypes: {},
/**
* 列属性字典
* @type {Map<string, GridColumnAttr>}
* @private
*/
colAttrs: {
/**
* 有已过滤的列
* @type {boolean}
* @private
*/
__filtered: false,
/**
* 过滤面板已打开
* @type {boolean}
* @private
*/
__filtering: false,
/**
* 上一个目标排序列索引
* @type {number}
* @private
*/
__orderIndex: -1,
},
/**
* 是否处于渲染中
* @type {boolean}
* @private
*/
rendering: false,
/**
* 头部高度
* @type {number}
* @private
*/
headerHeight: null,
/**
* 正文高度
* @type {number}
* @private
*/
containerHeight: null,
/**
* 合计行高度
* @type {number}
* @private
*/
footerHeight: null,
/**
* 合计行底边距偏移量
* @type {number}
* @private
*/
footerOffset: null,
/**
* 正文宽度
* @type {number}
* @private
*/
bodyClientWidth: null,
/**
* 是否需要 resize
* @type {boolean}
* @private
*/
needResize: null,
/**
* 页面元素引用
* @private
*/
refs: {
/**
* 表格引用 - table.ui-grid-table
* @type {HTMLTableElement}
* @private
*/
table: null,
/**
* 表格正文引用 - tbody
* @type {HTMLTableSectionElement}
* @private
*/
body: null,
/**
* 表格头部引用 - thead>tr
* @type {HTMLTableSectionElement}
* @private
*/
header: null,
/**
* 表格合计行引用 - tfooter>tr
* @type {HTMLTableSectionElement}
* @private
*/
footer: null,
/**
* 加载状态元素引用 - div.ui-grid-loading
* @type {HTMLDivElement}
* @private
*/
loading: null,
/**
* 大小计算元素引用 - span.ui-grid-sizer
* @type {HTMLSpanElement}
* @private
*/
sizer: null,
/**
* 包装元素引用 - div.ui-grid-wrapper
* @type {HTMLDivElement}
* @private
*/
wrapper: null,
/**
* 拖拽块引用 - div.dragger
* @type {HTMLDivElement}
* @private
*/
dragger: null,
/**
* 拖拽光标引用 - layer.dragger-cursor
* @type {HTMLElement}
* @private
*/
draggerCursor: null,
}
};
/**
* 列定义的数组
* @type {GridColumnDefinition[]}
* @ignore
*/
columns = [];
/**
* 合计行数据
* @type {Map<string, ValueItem>}
* @ignore
*/
total = null;
/**
* 多语言资源对象
* @ignore
*/
langs = {
/**
* @type {string}
* @default "( All )"
* @ignore
*/
all: null,
/**
* @type {string}
* @default "OK"
* @ignore
*/
ok: null,
/**
* @type {string}
* @default "Reset"
* @ignore
*/
reset: null,
/**
* @type {string}
* @default "Cancel"
* @ignore
*/
cancel: null,
/**
* @type {string}
* @default "( Null )"
* @ignore
*/
null: null,
/**
* @type {string}
* @default "Add Level"
* @ignore
*/
addLevel: null,
/**
* @type {string}
* @default "Delete Level"
* @ignore
*/
deleteLevel: null,
/**
* @type {string}
* @default "Copy Level"
* @ignore
*/
copyLevel: null,
/**
* @type {string}
* @default "Ascending"
* @ignore
*/
asc: null,
/**
* @type {string}
* @default "Descending"
* @ignore
*/
desc: null,
/**
* @type {string}
* @default "Column"
* @ignore
*/
column: null,
/**
* @type {string}
* @default "Order"
* @ignore
*/
order: null,
/**
* @type {string}
* @default "Sort"
* @ignore
*/
sort: null,
/**
* @type {string}
* @default "All sort criteria must have a column specified. Check the selected sort criteria and try again."
* @ignore
*/
requirePrompt: null,
/**
* @type {string}
* @default "{column} is being sorted more than once. Delete the duplicate sort criteria and try again."
* @ignore
*/
duplicatePrompt: null
};
/**
* 行数大于等于该值则启用虚模式
* @type {number}
* @default 100
* @ignore
*/
virtualCount = 100;
/**
* 表格行高
* @type {number}
* @default 36
* @ignore
*/
rowHeight = 36;
/**
* 文本行高(多行文本列计算高度时使用)
* @type {number}
* @default 24
* @ignore
*/
lineHeight = 24;
/**
* 列头过滤图标,旧版本样式(横着三个点)需修改为 `ellipsis-h`
* @type {string}
* @default "filter"
* @ignore
*/
filterIcon = 'filter';
/**
* 列表底部留出额外的空白行
* @type {number}
* @default 0
* @ignore
*/
extraRows = 0;
/**
* 过滤条件列表的行高
* @type {number}
* @default 30
* @ignore
*/
filterRowHeight = 30;
/**
* 列表高度值,为 0 时列表始终显示全部内容(自增高),为非数字或者小于 0 则根据容器高度来确定虚模式的渲染行数
* @type {number | null}
* @ignore
*/
height;
/**
* 是否允许多选
* @type {boolean}
* @default false
* @ignore
*/
multiSelect = false;
/**
* 为 `false` 时只有点击在单元格内才会选中行
* @type {boolean}
* @default true
* @ignore
*/
fullrowClick = true;
/**
* 单元格 tooltip 是否禁用
* @type {boolean}
* @default false
* @ignore
*/
tooltipDisabled = false;
/**
* 列头是否显示
* @type {boolean}
* @default true
* @ignore
*/
headerVisible = true;
/**
* 监听事件的窗口载体
* @type {(Window | HTMLElement)}
* @default window
* @ignore
*/
window;
/**
* 排序列的索引
* @type {number}
* @default -1
* @ignore
*/
sortIndex = -1;
/**
* 排序方式,正数升序,负数倒序
* @type {GridColumnDirection}
* @default GridColumnDirection.Ascending
* @ignore
*/
sortDirection = GridColumnDirection.Ascending;
/**
* 排序列数组
* @type {GridColumnSortDefinition[]}
* @default null
* @ignore
*/
sortArray = null;
/**
* 是否支持点击扩展
* @type {boolean}
* @default false
* @ignore
*/
expandable;
/**
* 扩展行生成器
* @type {GridExpandableObjectCallback}
* @ignore
*/
expandableGenerator;
/**
* 即将选中行时触发
* @event
* @param {number} index - 即将选中的行索引
* @param {number} colIndex - 即将选中的列索引
* @returns {boolean} 返回 `false`、`null`、`undefined`、`0` 等则取消选中动作
* @this Grid
*/
willSelect;
/**
* 单元格单击时触发colIndex 为 -1 则表示点击的是行的空白处
* @event
* @param {number} index - 点击的行索引
* @param {number} colIndex - 点击的列索引
* @returns {boolean} 返回 false 则取消事件冒泡
* @this Grid
*/
cellClicked;
/**
* 选中行发生变化时触发的事件
* @event
* @param {number} index - 选中的行索引
* @this Grid
*/
onSelectedRowChanged;
/**
* 单元格双击时触发的事件colIndex 为 -1 则表示点击的是行的空白处
* @event
* @param {number} index - 双击的行索引
* @param {number} colIndex - 双击的列索引
* @this Grid
*/
onCellDblClicked;
/**
* 行双击时触发的事件
* @event
* @param {number} index - 双击的行索引
* @this Grid
*/
onRowDblClicked;
/**
* 列发生变化时触发的事件
* @event
* @param {("reorder" | "resize" | "sort")} type - 事件类型
*
* * "reorder" 为发生列重排事件,此时 value 为目标列索引
* * "resize" 为发生列宽调整事件,此时 value 为列宽度值
* * "sort" 为发生列排序事件,此时 value 为 1升序或 -1倒序
* @param {number} colIndex - 发生变化事件的列索引
* @param {number | GridColumnDirection} value - 变化的值
* @this Grid
*/
onColumnChanged;
/**
* 列滚动时触发的事件
* @event
* @param {Event} e - 滚动事件对象
* @this Grid
*/
onBodyScrolled;
/**
* 扩展行展开时触发的事件
* @event
* @param {Map<string, ValueItem>} item - 行数据对象
* @param {GridExpandableObject} expandableObject - 由 [expandableGenerator]{@linkcode Grid#expandableGenerator} 产生的行扩展对象
* @param {HTMLElement} expandableObject.element - 扩展行元素
* @this Grid
*/
onRowExpanded;
/**
* 扩展行收缩时触发的事件
* @event
* @param {Map<string, ValueItem>} item - 行数据对象
* @param {GridExpandableObject} expandableObject - 由 [expandableGenerator]{@linkcode Grid#expandableGenerator} 产生的行扩展对象
* @param {HTMLElement} expandableObject.element - 扩展行元素
* @this Grid
*/
onRowCollapsed;
/**
* 列类型枚举
* @readonly
* @type {GridColumnTypeEnum}
* @property {number} Common=0 - 通用列(只读)
* @property {number} Input=1 - 单行文本列
* @property {number} Dropdown=2 - 下拉选择列
* @property {number} Checkbox=3 - 复选框列
* @property {number} Icon=4 - 图标列
* @property {number} Text=5 - 多行文本列
* @property {number} Date=6 - 日期选择列
*/
static get ColumnTypes() { return GridColumnTypeEnum }
/**
* Grid 控件构造函数
* @param {(string | HTMLElement)?} container Grid 控件所在的父容器,可以是 string 表示选择器,也可以是 HTMLElement 对象<br/>_**构造时可以不进行赋值,但是调用 init 函数时则必须进行赋值**_
* @param {Function} [getText] 获取多语言文本的函数代理
* @param {string} getText.id - 资源 ID
* @param {string} [getText.def] - 默认资源
* @param {string} getText.{returns} 返回的多语言
*/
constructor(container, getText) {
this._var.parent = typeof container === 'string' ? document.querySelector(container) : container;
if (typeof getText === 'function') {
r = getText;
}
this.langs = {
all: r('allItem', '( All )'),
ok: r('ok', 'OK'),
reset: r('reset', 'Reset'),
cancel: r('cancel', 'Cancel'),
null: r('null', '( Null )'),
addLevel: r('addLevel', 'Add level'),
deleteLevel: r('deleteLevel', 'Delete level'),
copyLevel: r('copyLevel', 'Copy level'),
asc: r('asc', 'Ascending'),
desc: r('desc', 'Descending'),
column: r('column', 'Column'),
order: r('order', 'Order'),
sort: r('sort', 'Sort'),
requirePrompt: r('requirePrompt', 'All sort criteria must have a column specified. Check the selected sort criteria and try again.'),
duplicatePrompt: r('duplicatePrompt', '{column} is being sorted more than once. Delete the duplicate sort criteria and try again.')
};
}
/**
* 获取 Grid 的页面元素
* @readonly
* @type {HTMLDivElement}
*/
get element() { return this._var.el }
/**
* 获取当前 Grid 是否已发生改变
* @readonly
* @type {boolean}
*/
get changed() {
const source = this._var.source;
if (source == null) {
return false;
}
return source.find(r => r.__changed) != null;
}
/**
* 返回所有数据的数据(未过滤)
* @readonly
* @type {Array<Map<string, ValueItem>>}
*/
get allSource() { return this._var.source?.map(s => s.values) }
/**
* 获取或设置 Grid 是否为只读
* @type {boolean}
*/
get readonly() { return this._var.readonly === true }
set readonly(flag) {
this._var.readonly = flag;
this.refresh();
}
/**
* 获取已过滤的数据数组,或者设置数据并刷新列表
* @type {Array<Map<string, ValueItem>>}
*/
get source() { return this._var.currentSource?.map(s => s.values) }
set source(list) {
if (this._var.el == null) {
throw new Error('grid has not been initialized.')
}
if (!Array.isArray(list)) {
throw new Error('source is not an Array.')
}
list = list.map((it, index) => {
return {
__index: index,
values: it
};
});
this._var.source = list;
this._refreshSource(list);
}
/**
* 获取已过滤的数据中的扩展对象数组
* @type {GridExpandableObject[]}
* @property {HTMLElement} element - 扩展行元素
*/
get sourceExpandable() { return this._var.currentSource?.map(s => s.__expandable_object) }
/**
* 设置单行数据
* @param {number} index - 行索引
* @param {Map<string, ValueItem>} item - 待设置的行数据对象
*/
setItem(index, item) {
if (this._var.currentSource == null) {
throw new Error('no source');
}
const it = this._var.currentSource[index];
// clear dropdown source cache
delete it.source;
it.values = item;
if (this.sortIndex >= 0) {
this.sortColumn();
} else if (this.sortArray?.length > 0) {
this.sort();
} else {
this.refresh();
}
}
/**
* 添加行数据
* @param {Map<string, ValueItem>} item - 待添加的行数据值
* @param {number} [index] - 待添加的行索引
* @returns {Map<string, ValueItem>} 返回已添加的行数据
*/
addItem(item, index) {
if (this._var.currentSource == null) {
throw new Error('no source');
}
const it = index >= 0 ? this._var.currentSource[index] : null;
const newIt = { __index: null, values: item };
if (it != null) {
newIt.__index = it.__index;
this._var.currentSource.splice(index, 0, newIt);
if (this._var.colAttrs.__filtered === true) {
this._var.source.splice(it.__index, 0, newIt);
}
for (let i = it.__index + 1; i < this._var.source.length; ++i) {
this._var.source[i].__index += 1;
}
} else {
newIt.__index = this._var.source.length;
this._var.currentSource.push(newIt);
if (this._var.colAttrs.__filtered === true) {
this._var.source.push(newIt);
}
}
if (this.sortIndex >= 0) {
this.sortColumn(true);
} else if (this.sortArray?.length > 0) {
this.sort(true);
} else {
this.reload();
}
return item;
}
/**
* 批量添加行数据
* @param {Array<Map<string, ValueItem>>} array - 待添加的行数据数组
* @param {number} [index] - 待添加的行索引
* @returns {Array<Map<string, ValueItem>>} 返回已添加的行数据数组
*/
addItems(array, index) {
if (this._var.currentSource == null) {
throw new Error('no source');
}
if (!Array.isArray(array) || array.length <= 0) {
// throw new Error(`invalid items array: ${array}`);
return;
}
const it = index >= 0 ? this._var.currentSource[index] : null;
if (it != null) {
const items = array.map((a, i) => ({ __index: it.__index + i, values: a }));
this._var.currentSource.splice(index, 0, ...items);
if (this._var.colAttrs.__filtered === true) {
this._var.source.splice(it.__index, 0, ...items);
}
const offset = array.length;
for (let i = it.__index + offset; i < this._var.source.length; ++i) {
this._var.source[i].__index += offset;
}
} else {
const length = this._var.source.length;
const items = array.map((a, i) => ({ __index: length + i, values: a }));
this._var.currentSource.push(...items);
if (this._var.colAttrs.__filtered === true) {
this._var.source.push(...items);
}
}
if (this.sortIndex >= 0) {
this.sortColumn(true);
} else if (this.sortArray?.length > 0) {
this.sort(true);
} else {
this.reload();
}
return array;
}
/**
* 删除行数据
* @param {number} index - 待删除的行索引
* @returns {Map<string, ValueItem>} 返回已删除的行数据
*/
removeItem(index) {
if (this._var.currentSource == null) {
throw new Error('no source');
}
const it = this._var.currentSource.splice(index, 1)[0];
if (it == null) {
return null;
}
if (this._var.colAttrs.__filtered === true) {
this._var.source.splice(it.__index, 1);
}
for (let i = it.__index; i < this._var.source.length; ++i) {
this._var.source[i].__index -= 1;
}
if (index < 1) {
this._var.selectedIndexes = [index - 1];
} else {
this._var.selectedIndexes = [];
}
this.reload();
return it.values;
}
/**
* 批量删除行数据
* @param {number[]} [indexes] - 待删除的行索引数组,未传值时删除所有行
* @returns {Array<Map<string, ValueItem>>} 返回已删除的行数据数组
*/
removeItems(indexes) {
if (this._var.currentSource == null) {
throw new Error('no source');
}
if (Array.isArray(indexes) && indexes.length > 0) {
indexes = indexes.slice().sort();
} else {
indexes = this._var.currentSource.map(a => a.__index);
}
const array = [];
let first = 0;
for (let i = indexes.length - 1; i >= 0; --i) {
let it = this._var.currentSource.splice(indexes[i], 1)[0];
if (it == null) {
continue;
}
let next = this._var.source[it.__index];
if (next != null && next.__offset == null) {
next.__offset = i + 1;
}
if (this._var.colAttrs.__filtered === true) {
this._var.source.splice(it.__index, 1);
}
array.splice(0, 0, it.values);
first = it.__index;
}
let offset = 1;
for (let i = first; i < this._var.source.length; ++i) {
let it = this._var.source[i];
if (it.__offset > 0) {
offset = it.__offset;
delete it.__offset;
}
it.__index -= offset;
}
const index = indexes[0];
if (index < 1) {
this._var.selectedIndexes = [index - 1];
} else {
this._var.selectedIndexes = [];
}
this.reload();
return array;
}
/**
* @private
* @param {GridItemWrapper[]} list
*/
_refreshSource(list) {
list ??= this._var.source;
if (this._var.colAttrs.__filtered === true) {
this._var.currentSource = list.filter(it => {
for (let col of this.columns) {
const nullValue = col.filterAllowNull ? null : '';
if (Array.isArray(col.filterValues)) {
const v = this._getItemProp(it.values, false, col) ?? nullValue;
if (col.filterValues.indexOf(v) < 0) {
return false;
}
}
}
return true;
});
} else {
this._var.currentSource = list;
}
this._var.selectedColumnIndex = -1;
this._var.selectedIndexes = [];
this._var.startIndex = 0;
this._var.scrollTop = 0;
this._var.scrollLeft = 0;
this._var.rowCount = -1;
this.resize(true, null, () => {
if (this.sortIndex >= 0) {
this.sortColumn(true);
} else if (this.sortArray?.length > 0) {
this.sort(true);
} else {
this.reload();
}
});
}
/**
* 获取当前是否为虚模式状态
* @readonly
* @type {boolean}
*/
get virtual() { return this._var.currentSource?.length > this.virtualCount }
/**
* 获取当前排序的列关键字,为 null 则当前无排序列
* @readonly
* @type {string | null}
*/
get sortKey() {
if (this.columns == null) {
return null;
}
return this.columns[this.sortIndex]?.key;
}
/**
* @private
* @type {HTMLTableRowElement[]}
*/
get _tableRows() {
// return [...this._var.refs.body.children];
return Array.prototype.slice.call(this._var.refs.body.querySelectorAll('&>.ui-grid-row'));
}
/**
* @private
* @type {HTMLTableCellElement[]}
*/
get _headerCells() {
return Array.prototype.slice.call(this._var.refs.header.querySelectorAll('&>th.column'));
}
/**
* @private
* @type {HTMLTableCellElement[]}
*/
get _footerCells() {
return Array.prototype.slice.call(this._var.refs.footer.querySelectorAll('&>.ui-grid-cell'));
}
/**
* 获取虚模式起始索引
* @readonly
* @type {number}
*/
get startIndex() { return this._var.startIndex }
/**
* 获取或设置当前选中的行索引的数组,设置后会刷新列表
* @type {number[]}
*/
get selectedIndexes() { return this._var.selectedIndexes }
set selectedIndexes(indexes) {
const startIndex = this._var.startIndex;
this._var.selectedIndexes.splice(0, this._var.selectedIndexes.length, ...indexes);
if (this.readonly) {
this._tableRows.forEach((row, i) => {
if (indexes.includes(startIndex + i)) {
row.classList.add('selected');
} else if (row.classList.contains('selected')) {
row.classList.remove('selected');
}
});
} else {
this.refresh();
}
if (typeof this.onSelectedRowChanged === 'function') {
this.onSelectedRowChanged();
}
}
/**
* 获取当前选中行的索引,为 -1 则当前没有选中行
* @readonly
* @type {number}
*/
get selectedIndex() { return (this._var.selectedIndexes && this._var.selectedIndexes[0]) ?? -1 }
/**
* 获取或设置 Grid 当前的加载状态
* @type {boolean}
*/
get loading() { return this._var.refs.loading?.style?.visibility === 'visible' }
set loading(flag) {
if (this._var.refs.loading == null) {
return;
}
if (flag === false) {
this._var.refs.loading.style.visibility = 'hidden';
this._var.refs.loading.style.opacity = 0;
} else {
this._var.refs.loading.style.visibility = 'visible';
this._var.refs.loading.style.opacity = 1;
}
}
/**
* 获取或设置 Grid 当前滚动的偏移量
* @type {number}
*/
get scrollTop() { return this._var.el?.scrollTop; }
set scrollTop(top) {
if (this._var.el == null) {
return;
}
this._var.el.scrollTop = top;
this.reload(true);
}
/**
* 初始化Grid控件
* @param {HTMLElement} [container=.ctor#container] - 父容器元素,若未传值则采用构造方法中传入的父容器元素
*/
init(container = this._var.parent) {
if (container == null) {
throw new Error('no specified parent.');
}
if (!(container instanceof HTMLElement)) {
const ele = container[0];
if (!(ele instanceof HTMLElement)) {
throw new Error(`parent type not supported. ${JSON.stringify(Object.getPrototypeOf(container))}`);
}
container = ele;
}
if (!Array.isArray(this.columns)) {
throw new Error('no specified column definitions.');
}
this._var.el = null;
this._var.refs = {};
this._var.rendering = true;
this._var.parent = container;
this._var.isFirefox = /Firefox\//i.test(navigator.userAgent);
const grid = createElement('div', 'ui-grid');
grid.setAttribute('tabindex', 0);
grid.addEventListener('keydown', e => {
let index = this.selectedIndex;
let flag = false;
if (e.key === 'ArrowUp') {
// up
if (index > 0) {
flag = true;
index -= 1;
}
} else if (e.key === 'ArrowDown') {
// down
const count = this._var.currentSource?.length ?? 0;
if (index < count - 1) {
flag = true;
index += 1;
}
}
if (flag) {
this._var.selectedIndexes = [index];
this.scrollToIndex(index);
this.refresh();
if (typeof this.onSelectedRowChanged === 'function') {
this.onSelectedRowChanged(index);
}
e.stopPropagation();
}
});
grid.addEventListener('mousedown', e => {
if (e.target === this._var.el) {
// cancel selections
const selectedIndexes = this._var.selectedIndexes;
if (selectedIndexes == null || selectedIndexes.length === 0) {
return;
}
selectedIndexes.splice(0);
if (this.readonly) {
this._tableRows.forEach(row => {
row.classList.remove('selected');
});
} else {
this.refresh();
}
if (typeof this.onSelectedRowChanged === 'function') {
this.onSelectedRowChanged(-1);
}
this._var.selectedColumnIndex = -1;
return;
}
let [parent, target] = this._getRowTarget(e.target);
if (parent == null) {
return;
}
const rowIndex = parent.classList.contains('ui-grid-total-row') ? -1 : indexOfParent(parent);
let colIndex = indexOfParent(target) - (this.expandable ? 1 : 0);
if (colIndex >= this.columns.length) {
colIndex = -1;
}
this._onRowClicked(e, rowIndex, colIndex);
});
container.replaceChildren(grid);
const sizer = createElement('span', 'ui-grid-sizer');
grid.appendChild(sizer);
this._var.refs.sizer = sizer;
grid.addEventListener('scroll', e => throttle(this._onScroll, RefreshInterval, this, e), { passive: true });
// header & body
const wrapper = createElement('div', 'ui-grid-wrapper');
this._var.refs.wrapper = wrapper;
grid.appendChild(wrapper);
const table = createElement('table', 'ui-grid-table');
this._var.refs.table = table;
this._createHeader(table);
this._createBody(table);
this._createFooter(table);
wrapper.appendChild(table);
// tooltip
if (!this.tooltipDisabled) {
const holder = createElement('div', 'ui-grid-hover-holder');
holder.addEventListener('mousedown', e => {
const holder = e.currentTarget;
const row = Number(holder.dataset.row);
const col = Number(holder.dataset.col);
if (holder.classList.contains('active')) {
holder.classList.remove('active');
}
return this._onRowClicked(e, row, col);
});
holder.addEventListener('dblclick', e => this._onRowDblClicked(e));
wrapper.appendChild(holder);
wrapper.addEventListener('mousemove', e => throttle(this._onGridMouseMove, HoverInternal, this, e, holder), { passive: true });
}
// loading
const loading = createElement('div', 'ui-grid-loading',
createElement('div', null, createIcon('fa-regular', 'spinner-third'))
);
this._var.refs.loading = loading;
grid.appendChild(loading);
this._var.el = grid;
this._var.rendering = false;
if (this._var.source != null) {
if (this.sortIndex >= 0) {
this.sortColumn(true);
} else if (this.sortArray?.length > 0) {
this.sort(true);
} else {
this.reload();
}
}
}
/**
* 设置数据列表,该方法为 [source]{@linkcode Grid#source} 属性的语法糖
* @param {Array<Map<string, ValueItem>>} source - 待设置的数据列表
*/
setData(source) {
this.source = source;
}
/**
* 滚动到指定行的位置
* @param {number} index - 待滚动至的行索引
*/
scrollToIndex(index) {
const top = this._scrollToTop(index * (this.rowHeight + 1), true);
this._var.el.scrollTop = top;
}
/**
* 调整 Grid 元素的大小,一般需要在宽度变化时(如页面大小发生变化时)调用
* @param {boolean} [force] - 是否强制 [reload]{@linkcode Grid#reload},默认只有待渲染的行数发生变化时才会调用
* @param {boolean} [keep] - 是否保持当前滚动位置
* @param {Function} [callback] - 计算大小后的回调函数
* @param {Grid} callback.{this} - 上下文为 Grid
*/
resize(force, keep, callback) {
if (this._var.rendering || this._var.el == null) {
return;
}
const body = this._var.refs.body;
const top = this.headerVisible === false ? 0 : this._var.refs.header.offsetHeight;
let height = this.height;
if (height === 0) {
height = this._var.containerHeight;
} else if (isNaN(height) || height < 0) {
height = this._var.el.offsetHeight - top;
}
const count = truncate((height - 1) / (this.rowHeight + 1)) + (RedumCount * 2) + 1;
if (force || count !== this._var.rowCount) {
this._var.rowCount = count;
if (typeof callback === 'function') {
callback.call(this);
} else {
this.reload(keep);
}
}
this._var.bodyClientWidth = body.clientWidth;
}
/**
* 重新计算需要渲染的行,并载入元素,一般需要在高度变化时调用
* @param {boolean} [keep] - 是否保持当前滚动位置
*/
reload(keep) {
const filtered = this.columns.some(c => c.filterValues != null);
if ((filtered ^ this._var.colAttrs.__filtered) === 1) {
this._var.colAttrs.__filtered = filtered;
const headers = this._headerCells;
for (let i = 0; i < this.columns.length; ++i) {
const ele = headers[i].querySelector('.filter');
if (ele == null) {
continue;
}
if (this.columns[i].filterValues != null) {
ele.classList.add('active');
} else {
ele.classList.remove('active');
}
}
this._refreshSource();
return;
} else if (filtered) {
const headers = this._headerCells;
for (let i = 0; i < this.columns.length; ++i) {
const ele = headers[i].querySelector('.filter');
if (ele == null) {
continue;
}
if (this.columns[i].filterValues != null) {
ele.classList.add('active');
} else {
ele.classList.remove('active');
}
}
}
let length = this._var.currentSource?.length ?? 0;
if (this.extraRows > 0) {
length += this.extraRows;
}
this._var.containerHeight = length * (this.rowHeight + 1);
if (!keep) {
this._var.scrollTop = 0;
this._var.el.scrollTop = 0;
// this._var.el.scrollLeft = 0;
this._var.refs.table.style.top = '0px';
}
this._adjustRows(this._var.refs.body);
this.refresh();
// size adjustment
const headerHeight = this._var.headerHeight || this.rowHeight;
if (this.total != null) {
const footerHeight = this._var.footerHeight || this.rowHeight;
this._var.refs.wrapper.style.height = `${headerHeight + this._var.containerHeight + footerHeight}px`;
// footer position
this._var.footerOffset = this._var.refs.table.offsetHeight - this._var.el.clientHeight;
this._var.refs.footer.parentElement.style.bottom = `${this._var.refs.table.offsetTop + this._var.footerOffset - this._var.el.scrollTop}px`;
} else {
this._var.refs.wrapper.style.height = `${headerHeight + this._var.containerHeight}px`;
}
}
/**
* 重新填充Grid单元格数据
*/
refresh() {
if (this._var.refs.body == null) {
throw new Error('body has not been created.');
}
const widths = {};
this._fillRows(this._tableRows, this.columns, widths);
if (this._var.needResize && widths.flag) {
this._var.needResize = false;
this.columns.forEach((col, i) => {
if (!this._get(col.key, 'autoResize')) {
return;
}
let width = widths[i];
if (width < col.width) {
width = col.width;
}
if (width > 0) {
this._changeColumnWidth(i, width);
}
});
} else {
const children = this._var.refs.table.children;
this._var.headerHeight = children[0].offsetHeight;
if (children.length > 2) {
this._var.footerHeight = children[2].offsetHeight;
}
}
}
/**
* 把所有行重置为未修改的状态
*/
resetChange() {
if (this._var.source == null) {
return;
}
for (let row of this._var.source) {
delete row.__changed;
}
}
/**
* @private
* @callback PrivateGridComparerCallback
* @param {GridItemWrapper} a
* @param {GridItemWrapper} b
* @returns {number}
*/
/**
* @private
* @param {GridColumnDefinition} col
* @param {GridColumnDirection} direction
* @returns {PrivateGridComparerCallback}
*/
_getComparer(col, direction) {
if (typeof col.sortFilter !== 'function') {
if (isNaN(direction)) {
direction = 1;
}
return (a, b) => {
a = this._getItemProp(a.values, true, col);
b = this._getItemProp(b.values, true, col);
if (a == null && typeof b === 'number') {
a = 0;
} else if (typeof a === 'number' && b == null) {
b = 0;
} else if (a != null && b == null) {
return direction;
} else if (typeof a === 'string' && typeof b === 'string') {
a = a.toLowerCase();
b = b.toLowerCase();
}
return a === b ? 0 : (a > b ? 1 : -1) * direction;
};
}
return (a, b) => col.sortFilter(a.values, b.values) * direction;
}
/**
* 根据当前排序字段进行列排序
* @param {boolean} [reload] - 为 `true` 则在列排序后调用 [reload]{@linkcode Grid#reload} 方法
*/
sortColumn(reload) {
const index = this.sortIndex;
const col = this.columns[index];
if (col == null) {
return;
}
this.sortArray = null;
const direction = this.sortDirection;
[...this._headerCells].forEach((th, i) => {
const arrow = th.querySelector('.arrow');
if (arrow == null) {
return;
}
if (i === index) {
arrow.className = `arrow ${(direction !== 1 ? 'desc' : 'asc')}`;
} else if (arrow.className !== 'arrow') {
arrow.className = 'arrow';
}
});
const comparer = this._getComparer(col, direction);
this._var.source.sort(comparer);
if (this._var.colAttrs.__filtered === true) {
this._var.currentSource.sort(comparer);
}
if (this._var.rowCount < 0) {
return;
}
if (reload) {
this.reload();
} else {
this.refresh();
}
}
/**
* 根据当前排序列数组进行多列排序
* @param {boolean} [reload] - 为 `true` 则在多列排序后调用 [reload]{@linkcode Grid#reload} 方法
*/
sort(reload) {
const sortArray = this.sortArray;
if (sortArray == null || sortArray.length === 0) {
return;
}
this.sortIndex = -1;
const comparer = (a, b) => {
for (let i = 0; i < sortArray.length; ++i) {
const s = sortArray[i];
const col = this.columns.find(c => c.key === s.column && c.visible !== false);
if (col != null) {
const result = this._getComparer(col, s.order === 'desc' ? -1 : 1)(a, b);
if (result !== 0) {
return result;
}
}
}
return 0;
};
this._var.source.sort(comparer);
if (this._var.colAttrs.__filtered === true) {
this._var.currentSource.sort(comparer);
}
if (this._var.rowCount < 0) {
return;
}
if (reload) {
this.reload();
} else {
this.refresh();
}
// arrow icon
[...this._headerCells].forEach((th, i) => {
const arrow = th.querySelector('.arrow');
if (arrow == null) {
return;
}
const col = this.columns[i];
const s = sortArray.find(s => s.column === col.key && col.visible !== false);
if (s != null) {
arrow.className = `arrow ${s.order}`;
} else if (arrow.className !== 'arrow') {
arrow.className = 'arrow';
}
});
}
/**
* 清除列头复选框的选中状态
*/
clearHeaderCheckbox() {
const boxes = this._var.refs.header.querySelectorAll('.ui-check-wrapper>input');
boxes.forEach(box => box.checked = false);
}
/**
* 显示多列排序设置面板
*/
showSortPanel() {
const content = createElement('div', 'ui-sort-panel-content');
const buttonWrapper = createElement('div', 'ui-sort-panel-buttons');
const grid = new Grid(null, r);
grid.langs = this.langs;
const rowChanged = index => {
buttonWrapper.querySelector('.ui-button-delete').disabled = index < 0;
buttonWrapper.querySelector('.ui-button-copy').disabled = index < 0;
buttonWrapper.querySelector('.ui-button-move-up').disabled = index < 1;
buttonWrapper.querySelector('.ui-button-move-down').disabled = index >= grid.source.length - 1;
};
grid.onSelectedRowChanged = rowChanged;
const reload = index => {
grid.selectedIndexes = [index];
grid.scrollTop = index * (grid.rowHeight + 1);
rowChanged(index);
}
buttonWrapper.append(
createElement('button', null,
createIcon('fa-light', 'plus'),
createElement('span', span => {
span.innerText = this.langs.addLevel;
span.addEventListener('click', () => {
let index = grid.selectedIndex;
const n = { column: '', order: 'asc' };
if (index >= 0) {
index += 1;
grid.addItem(n, index);
} else {
grid.addItem(n);
index = grid.source.length - 1;
}
reload(index);
});
})
),
createElement('button', 'ui-button-delete',
createIcon('fa-light', 'times'),
createElement('span', span => {
span.innerText = this.langs.deleteLevel;
span.addEventListener('click', () => {
let index = grid.selectedIndex;
if (index < 0) {
return;
}
grid.removeItem(index);
const length = grid.source.length;
if (index >= length) {
index = length - 1;
}
reload(index);
});
})
),
createElement('button', 'ui-button-copy',
createIcon('fa-light', 'copy'),
createElement('span', span => {
span.innerText = this.langs.copyLevel;
span.addEventListener('click', () => {
const index = grid.selectedIndex;
if (index < 0) {
return;
}
const item = grid.source[index];
if (item == null) {
return;
}
grid.addItem(Object.assign({}, item), index + 1);
reload(index + 1);
});
})
),
createElement('button', button => {
button.className = 'ui-button-move-up';
const icon = createIcon('fa-light', 'chevron-up');
icon.addEventListener('click', () => {
const index = grid.selectedIndex;
if (index < 1) {
return;
}
const item = grid.source[index];
if (item == null) {
return;
}
const it = grid.removeItem(index);
grid.addItem(it, index - 1);
reload(index - 1);
});
button.appendChild(icon);
}),
createElement('button', button => {
button.className = 'ui-button-move-down';
const icon = createIcon('fa-light', 'chevron-down');
icon.addEventListener('click', () => {
const index = grid.selectedIndex;
if (index >= grid.source.length - 1) {
return;
}
const item = grid.source[index];
if (item == null) {
return;
}
const it = grid.removeItem(index);
grid.addItem(it, index + 1);
reload(index + 1);
});
button.appendChild(icon);
})
);
const gridWrapper = createElement('div', 'ui-sort-panel-grid');
content.append(buttonWrapper, gridWrapper);
const columnSource = this.columns.filter(c => c.sortable !== false && c.visible !== false);
grid.columns = [
{
key: 'column',
caption: this.langs.column,
width: 270,
type: GridColumnTypeEnum.Dropdown,
dropOptions: {
textKey: 'caption',
valueKey: 'key'
},
source: columnSource,
sortable: false,
orderable: false
},
{
key: 'order',
caption: this.langs.order,
width: 150,
type: GridColumnTypeEnum.Dropdown,
source: [
{ value: 'asc', text: this.langs.asc },
{ value: 'desc', text: this.langs.desc }
],
sortable: false,
orderable: false
}
];
const pop = new Popup({
title: this.langs.sort,
content,
resizable: true,
buttons: [
{
text: this.langs.ok,
trigger: () => {
const source = grid.source;
if (source == null || source.length === 0) {
this.sortArray = null;
} else {
const dict = {};
for (let i = 0; i < source.length; ++i) {
const it = source[i];
if (it.column == null || it.column === '') {
grid.selectedIndexes = [i];
grid.refresh();
showAlert(this.langs.sort, this.langs.requirePrompt, 'warn');
return false;
}
if (Object.prototype.hasOwnProperty.call(dict, it.column)) {
grid.selectedIndexes = [i];
grid.refresh();
let name = columnSource.find(c => c.key === it.column);
if (name == null) {
name = it.column;
} else {
name = name.caption;
}
showAlert(this.langs.sort, this.langs.duplicatePrompt.replace('{column}', name), 'warn');
return false;
}
dict[it.column] = true;
}
this.sortArray = source;
this.sortDirection = 1;
this.sort();
}
if (typeof this.onSorted === 'function') {
this.onSorted(this.sortArray);
}
return true;
}
},
{ text: this.langs.cancel }
],
onResizeEnded: () => grid.resize()
});
const source = this.sortArray || [{ column: '', order: 'asc' }];
pop.show(this._var.el).then(() => {
pop.container.style.cssText += 'width: 520px; height: 400px';
grid.init(gridWrapper);
grid.source = source.filter(s => s.column === '' || columnSource.find(c => c.key === s.column) != null);
grid.selectedIndexes = [0];
grid.refresh();
rowChanged(0);
});
}
/**
* @private
* @param {HTMLTableElement} table
* @returns {HTMLTableSectionElement}
*/
_createHeader(table) {
const thead = createElement('thead');
if (this.headerVisible === false) {
thead.style.display = 'none';
}
table.appendChild(thead);
const header = createElement('tr');
thead.appendChild(header);
const sizer = this._var.refs.sizer;
let left = this.expandable ? ExpandableWidth : 0;
const readonly = this.readonly;
if (this.expandable) {
header.appendChild(createElement('th', th => {
th.className = 'ui-expandable sticky';
const w = `${ExpandableWidth}px`;
th.style.cssText = convertCssStyle({
'width': w,
'max-width': w,
'min-width': w,
'left': '0px'
});
}, createElement('div')));
}
for (let col of this.columns) {
if (col.visible === false) {
const hidden = createElement('th', 'column');
hidden.style.display = 'none';
if (col.sortable !== false) {
hidden.dataset.key = col.key;
hidden.addEventListener('click', e => this._onHeaderClicked(e, col, true));
}
header.appendChild(hidden);
continue;
}
// style
const isCheckbox = GridColumnTypeEnum.isCheckbox(col.type);
let type = this._var.colTypes[col.key];
if (type == null) {
if (isNaN(col.type)) {
type = col.type;
} else {
type = ColumnTypeDefs[col.type];
}
type ??= GridColumn;
this._var.colTypes[col.key] = type;
}
if (col.width > 0 || col.shrink || typeof type.createCaption === 'function') {
// col.autoResize = false;
} else {
this._set(col.key, 'autoResize', true);
this._var.needResize = true;
sizer.innerText = col.caption ?? '';
let width = sizer.offsetWidth + 22;
if (!readonly && col.enabled !== false && col.allcheck && isCheckbox) {
width += 32;
}
if (col.allowFilter === true) {
width += 14;
}
if (width < MiniColumnWidth) {
width = MiniColumnWidth;
}
col.width = width;
}
col.align ??= isCheckbox ? 'center' : 'left';
if (col.sortable !== false) {
col.sortable = true;
}
let style;
if (col.shrink) {
style = { 'text-align': col.align };
} else {
const w = `${col.width}px`;
style = {
'width': w,
'max-width': w,
'min-width': w,
'text-align': col.align
};
}
this._set(col.key, 'style', style);
// element
const th = createElement('th', 'column');
const thStyle = { ...style };
if (col.isfixed) {
th.classList.add('sticky');
thStyle.left = `${left}px`;
}
left += col.width;
th.dataset.key = col.key;
if (col.sortable) {
thStyle.cursor = 'pointer';
th.addEventListener('click', e => this._onHeaderClicked(e, col));
}
th.style.cssText = convertCssStyle(thStyle);
if (col.orderable !== false) {
col.orderable = true;
th.addEventListener('mousedown', e => this._onDragStart(e, col));
}
const wrapper = createElement('div');
if (col.align === 'right') {
wrapper.style.justifyContent = 'flex-end';
} else if (col.align === 'center') {
wrapper.style.justifyContent = 'center';
}
th.appendChild(wrapper);
if (!readonly && col.enabled !== false && col.allcheck && isCheckbox) {
const check = createCheckbox({
onchange: e => this._onColumnAllChecked(col, e.target.checked)
});
wrapper.appendChild(check);
}
let caption;
if (typeof type.createCaption === 'function') {
caption = type.createCaption(col);
} else {
caption = createElement('span');
caption.innerText = col.caption ?? '';
}
if (caption instanceof HTMLElement) {
if (col.captionStyle != null) {
caption.style.cssText = convertCssStyle(col.captionStyle);
}
wrapper.appendChild(caption);
}
// order arrow
if (col.sortable) {
th.appendChild(createElement('layer', 'arrow'));
}
// filter
if (col.allowFilter === true) {
const filter = createElement('layer', 'filter');
filter.appendChild(createIcon('fa-solid', this.filterIcon));
filter.addEventListener('mousedown', e => this._onFilter(e, col));
th.classList.add('header-filter');
th.appendChild(filter);
}
// resize spliter
if (col.resizable !== false) {
const spliter = createElement('layer', 'spliter');
spliter.addEventListener('mousedown', e => this._onResizeStart(e, col));
spliter.addEventListener('dblclick', e => this._onAutoResize(e, col));
th.appendChild(spliter);
}
// tooltip
// !nullOrEmpty(col.tooltip) && setTooltip(th, col.tooltip);
header.appendChild(th);
}
const dragger = createElement('div', 'dragger');
const draggerCursor = createElement('layer', 'dragger-cursor');
header.appendChild(
createElement('th', null, dragger, draggerCursor, createElement('div')));
sizer.replaceChildren();
this._var.refs.header = header;
this._var.refs.dragger = dragger;
this._var.refs.draggerCursor = draggerCursor;
return thead;
}
/**
* @private
* @param {HTMLTableElement} table
* @returns {HTMLTableSectionElement}
*/
_createBody(table) {
const body = createElement('tbody');
table.appendChild(body);
const cols = this.columns;
let width = 1;
for (let col of cols) {
if (col.visible !== false && !isNaN(col.width)) {
width += col.width + 1;
}
}
if (this.expandable) {
width += ExpandableWidth;
}
table.style.width = `${width}px`;
// body content
body.addEventListener('dblclick', e => this._onRowDblClicked(e));
// this._adjustRows(body);
this._var.refs.body = body;
// this.refresh();
return body;
}
/**
* @private
* @param {HTMLTableElement} table
* @returns {HTMLTableSectionElement}
*/
_createFooter(table) {
const tfoot = createElement('tfoot');
tfoot.style.display = 'none';
table.appendChild(tfoot);
tfoot.addEventListener('dblclick', e => this._onRowDblClicked(e));
const footer = createElement('tr', 'ui-grid-row ui-grid-total-row');
tfoot.appendChild(footer);
let left = this.expandable ? ExpandableWidth : 0;
if (this.expandable) {
footer.appendChild(createElement('td', td => {
td.className = 'ui-expandable sticky';
const w = `${ExpandableWidth}px`;
td.style.cssText = convertCssStyle({
'width': w,
'max-width': w,
'min-width': w,
'left': '0px'
});
}, createElement('div')));
}
this.columns.forEach((col, j) => {
const cell = createElement('td', 'ui-grid-cell');
if (col.visible !== false) {
let style = this._get(col.key, 'style') ?? {};
if (col.isfixed) {
cell.classList.add('sticky');
style.left = `${left}px`;
}
left += col.width;
cell.dataset.col = String(j);
if (col.totalCss != null) {
style = { ...style, ...col.totalCss };
}
style = convertCssStyle(style);
if (style !== '') {
cell.style.cssText = style;
}
const element = GridColumn.create(col)
if (typeof col.class === 'string') {
GridColumn.setClass(element, col.class);
}
cell.appendChild(element);
} else {
cell.style.display = 'none';
}
footer.appendChild(cell);
})
footer.appendChild(createElement('td', td => td.innerText = '\u00a0'));
this._var.refs.footer = footer;
return tfoot;
}
/**
* @private
* @param {HTMLTableSectionElement} content
*/
_adjustRows(content) {
let count = this._var.rowCount;
if (isNaN(count) || count < 0 || !this.virtual) {
count = this._var.currentSource?.length ?? 0;
}
const cols = this.columns;
const exists = content.querySelectorAll('&>.ui-grid-row').length;
count -= exists;
if (count > 0) {
const readonly = this.readonly;
for (let i = 0; i < count; ++i) {
const row = createElement('tr', 'ui-grid-row');
let left = this.expandable ? ExpandableWidth : 0;
if (this.expandable) {
const icon = createIcon('fa-solid', 'caret-right');
icon.dataset.expanded = '0';
row.appendChild(createElement('td', td => {
td.className = 'ui-expandable sticky';
td.style.cssText = 'left: 0px';
td.addEventListener('mousedown', e => {
this._onExpandable(e, exists + i, row);
e.stopPropagation();
});
},
icon
));
}
cols.forEach((col, j) => {
const cell = createElement('td', 'ui-grid-cell');
if (col.visible !== false) {
let style = this._get(col.key, 'style') ?? {};
if (col.isfixed) {
cell.classList.add('sticky');
style.left = `${left}px`;
}
left += col.width;
cell.dataset.row = String(exists + i);
cell.dataset.col = String(j);
if (col.css != null) {
style = { ...style, ...col.css };
}
style = convertCssStyle(style);
if (style !== '') {
cell.style.cssText = style;
}
if (!readonly && GridColumnTypeEnum.isCheckbox(col.type)) {
cell.appendChild(GridCheckboxColumn.createEdit(e => this._onRowChanged(e, exists + i, col, e.target.checked, cell)));
// this._var.colTypes[col.key] = GridCheckboxColumn;
} else {
let type = this._var.colTypes[col.key];
if (type == null) {
if (isNaN(col.type)) {
type = col.type;
} else {
type = ColumnTypeDefs[col.type];
}
type ??= GridColumn;
this._var.colTypes[col.key] = type;
}
const element = type.create(col, i, this);
if (typeof col.class === 'string') {
type.setClass(element, col.class);
}
cell.appendChild(element);
}
} else {
cell.style.display = 'none';
}
row.appendChild(cell);
});
row.appendChild(createElement('td', td => td.innerText = '\u00a0'));
content.appendChild(row);
}
} else if (count < 0) {
let last = content.querySelectorAll('&>.ui-grid-row')[exists + count];
while (last != null) {
const next = last.nextElementSibling;
last.remove();
last = next;
}
}
}
/**
* @private
* @param {HTMLTableRowElement[]} rows
* @param {GridColumnDefinition[]} cols
* @param {any} widths
*/
_fillRows(rows, cols, widths) {
const startIndex = this._var.startIndex;
const selectedIndexes = this._var.selectedIndexes;
const stateChanged =
this._var.oldIndex !== startIndex ||
selectedIndexes == null ||
this._var.oldSelectedIndexes?.length !== selectedIndexes.length ||
this._var.oldSelectedIndexes.find((s, i) => s !== selectedIndexes[i]) != null;
if (stateChanged) {
this._var.oldIndex = startIndex;
if (selectedIndexes != null) {
this._var.oldSelectedIndexes = selectedIndexes.slice();
}
}
const offset = this.expandable ? 1 : 0;
const readonly = this.readonly;
rows.forEach((row, i) => {
const vals = this._var.currentSource[startIndex + i];
if (vals == null) {
return;
}
if (!isPositive(row.children.length)) {
return;
}
const item = vals.values;
const selected = selectedIndexes.includes(startIndex + i);
if (selected) {
row.classList.add('selected');
} else if (row.classList.contains('selected')) {
row.classList.remove('selected');
}
// data
if (this.expandable) {
const expanded = vals.__expanded;
let rowExpanded = row.nextElementSibling;
if (rowExpanded?.className !== 'ui-grid-row-expanded') {
rowExpanded = null;
}
if (expanded) {
let expandableObject = vals.__expandable_object;
if (expandableObject == null && typeof this.expandableGenerator === 'function') {
expandableObject = this.expandableGenerator(item);
if (expandableObject?.element == null) {
return;
}
expandableObject.element = createElement('td', td => {
td.colSpan = cols.length + 2;
},
expandableObject.element
);
vals.__expandable_object = expandableObject;
}
if (rowExpanded == null) {
rowExpanded = createElement('tr', 'ui-grid-row-expanded');
this._var.refs.body.insertBefore(rowExpanded, row.nextElementSibling);
} else {
rowExpanded.style.display = '';
}
rowExpanded.replaceChildren(expandableObject.element);
} else {
if (rowExpanded != null) {
rowExpanded.style.display = 'none';
}
}
const iconCell = row.children[0];
if (iconCell.children[0].dataset.expanded !== (expanded ? '1' : '0')) {
const icon = createIcon('fa-solid', expanded ? 'caret-down' : 'caret-right');
icon.dataset.expanded = expanded ? '1' : '0';
iconCell.replaceChildren(icon);
if (expanded) {
if (typeof this.onRowExpanded === 'function') {
this.onRowExpanded(vals.values, vals.__expandable_object);
}
} else {
if (typeof this.onRowCollapsed === 'function') {
this.onRowCollapsed(vals.values, vals.__expandable_object);
}
}
}
}
cols.forEach((col, j) => {
if (col.visible === false) {
return;
}
const cell = row.children[j + offset];
if (cell == null) {
return;
}
let val;
if (col.text != null) {
val = col.text;
} else if (typeof col.filter === 'function') {
val = col.filter(item, selected, this._var.refs.body);
} else {
val = item[col.key];
if (val?.DisplayValue != null) {
val = val.DisplayValue;
}
}
val ??= '';
// fill
let bg = col.background;
if (bg != null) {
if (typeof bg === 'function') {
bg = col.background(item);
}
cell.style.backgroundColor = bg ?? '';
} else if (typeof col.bgFilter === 'function') {
const bgColor = col.bgFilter(item);
cell.style.backgroundColor = bgColor ?? '';
}
const isCheckbox = GridColumnTypeEnum.isCheckbox(col.type);
const type = isCheckbox ? GridCheckboxColumn : this._var.colTypes[col.key] ?? GridColumn;
let element;
if (!readonly && !isCheckbox && typeof type.createEdit === 'function') {
if (vals.__editing?.[col.key]) {
if (typeof type.leaveEdit === 'function') {
type.leaveEdit(cell.children[0], this._var.el);
}
if (type.editing) {
val = type.getValue({ target: cell.children[0] }, col);
this._onRowChanged(null, i, col, val, cell, true);
}
}
if (stateChanged) {
element = selected ?
type.createEdit(e => this._onRowChanged(e, i, col, type.getValue(e, col), cell), col, this._var.el, vals) :
type.create(col, i, this);
if (typeof col.class === 'string') {
type.setClass(element, col.class);
}
cell.replaceChildren(element);
} else {
element = cell.children[0];
}
} else {
element = cell.children[0];
}
let enabled;
if (readonly) {
enabled = false;
} else {
enabled = col.enabled;
if (typeof enabled === 'function') {
enabled = enabled.call(col, item);
} else if (typeof enabled === 'string') {
enabled = item[enabled];
}
}
type.setValue(element, val, vals, col, this);
let tip = col.tooltip;
if (typeof tip === 'function') {
tip = tip.call(col, item);
}
if (nullOrEmpty(tip)) {
element.querySelector('.ui-tooltip-wrapper')?.remove();
} else {
setTooltip(element, tip, false, this.element);
}
if (typeof type.setEnabled === 'function') {
type.setEnabled(element, enabled);
}
// auto resize
if (this._var.needResize && this._get(col.key, 'autoResize')) {
const width = element.scrollWidth + 12;
if (width > 0 && widths != null && (isNaN(widths[j]) || widths[j] < width)) {
widths[j] = width;
widths.flag = true;
}
}
let style = col.style;
if (style != null) {
if (typeof style === 'function') {
style = col.style(item);
}
if (style != null) {
type.setStyle(element, style);
} else {
element.style.cssText = '';
}
} else if (typeof col.styleFilter === 'function') {
const style = col.styleFilter(item);
if (style != null) {
type.setStyle(element, style);
} else {
element.style.cssText = '';
}
}
if (col.events != null) {
for (let ev of Object.entries(col.events)) {
element[ev[0]] = ev[1].bind(item);
}
}
if (col.attrs != null) {
let attrs = col.attrs;
if (typeof attrs === 'function') {
attrs = attrs(item);
}
for (let attr of Object.entries(attrs)) {
element.setAttribute(attr[0], attr[1]);
}
}
});
if (vals.__editing != null) {
delete vals.__editing;
}
});
// total
const tfoot = this._var.refs.footer.parentElement;
if (this.total != null) {
if (tfoot.style.display === 'none') {
tfoot.style.display = '';
}
const cells = this._var.refs.footer.children;
this.columns.forEach((col, j) => {
if (col.visible === false) {
return;
}
const cell = cells[j + offset];
if (cell == null) {
return;
}
let val = this.total[col.key];
if (val?.DisplayValue != null) {
val = val.DisplayValue;
}
val ??= '';
const element = cell.children[0];
GridColumn.setValue(element, val);
// auto resize
if (this._var.needResize && this._get(col.key, 'autoResize')) {
const width = element.scrollWidth + 12;
if (width > 0 && widths != null && (isNaN(widths[j]) || widths[j] < width)) {
widths[j] = width;
widths.flag = true;
}
}
});
} else if (tfoot.style.display === '') {
tfoot.style.display = 'none';
}
}
/**
* @private
* @param {number} index
* @param {number} width
*/
_changeColumnWidth(index, width) {
const col = this.columns[index];
// const oldwidth = col.width;
const w = `${width}px`;
col.width = width;
const style = this._get(col.key, 'style');
style.width = w;
style['max-width'] = w;
style['min-width'] = w;
const headerCells = this._headerCells;
let element = headerCells[index];
element.style.width = w;
element.style.maxWidth = w;
element.style.minWidth = w;
// element.style.cssText += `width: ${w}; max-width: ${w}; min-width: ${w}`;
let left = this.expandable ? ExpandableWidth : 0;
if (col.isfixed) {
left = element.offsetLeft + width;
let l = left;
for (let i = index + 1; i < this.columns.length; ++i) {
if (this.columns[i].isfixed) {
headerCells[i].style.left = `${l}px`;
l += this.columns[i].width;
} else {
break;
}
}
}
const offset = this.expandable ? 1 : 0;
for (let row of this._tableRows) {
element = row.children[index + offset];
if (element != null) {
element.style.width = w;
element.style.maxWidth = w;
element.style.minWidth = w;
// element.style.cssText += `width: ${w}; max-width: ${w}; min-width: ${w}`;
if (col.isfixed) {
let l = left;
for (let i = index + offset + 1; i < this.columns.length; ++i) {
if (this.columns[i].isfixed) {
row.children[i].style.left = `${l}px`;
l += this.columns[i].width;
} else {
break;
}
}
}
}
}
// footer
const children = this._var.refs.table.children;
const hasTotal = this.total != null;
if (hasTotal) {
const footerCells = this._footerCells;
element = footerCells[index];
element.style.width = w;
element.style.maxWidth = w;
element.style.minWidth = w;
if (col.isfixed) {
let l = left;
for (let i = index + 1; i < this.columns.length; ++i) {
if (this.columns[i].isfixed) {
footerCells[i].style.left = `${l}px`;
l += this.columns[i].width;
} else {
break;
}
}
}
}
this._var.headerHeight = children[0].offsetHeight;
if (hasTotal) {
this._var.footerHeight = children[2].offsetHeight;
const footerOffset = this._var.refs.table.offsetHeight - this._var.el.clientHeight;
if (this._var.footerOffset !== footerOffset) {
this._var.footerOffset = footerOffset;
this._var.refs.footer.parentElement.style.bottom = `${this._var.refs.table.offsetTop + footerOffset - this._var.el.scrollTop}px`;
}
}
}
/**
* @private
* @param {number} index
* @param {number} offset
* @param {number} mouse
* @param {number} draggerCellLeft
*/
_changingColumnOrder(index, offset, mouse, draggerCellLeft) {
const children = this._headerCells;
let element = children[index];
this._var.refs.dragger.style.cssText = `left: ${element.offsetLeft - draggerCellLeft + offset}px; width: ${element.style.width}; display: block`;
// offset = x + gridScrollLeft - element.offsetLeft; // getOffsetLeftFromWindow(element);
offset += mouse;
let idx;
const toLeft = offset < 0;
if (toLeft) {
offset = -offset;
for (let i = index - 1; i >= 0 && offset >= 0; i -= 1) {
element = children[i];
if (element == null || !element.className || element.classList.contains('sticky')) {
idx = i + 1;
break;
}
if (offset < element.offsetWidth) {
idx = (offset > element.offsetWidth / 2) ? i : i + 1;
break;
}
offset -= element.offsetWidth;
}
idx ??= 0;
} else {
const count = children.length;
for (let i = index; i < count - 1 && offset >= 0; ++i) {
element = children[i];
if (element == null || !element.className || element.classList.contains('sticky')) {
idx = i;
break;
}
if (offset < element.offsetWidth) {
idx = (offset > element.offsetWidth / 2) ? i + 1 : i;
break;
}
offset -= element.offsetWidth;
}
idx ??= count - 1;
}
if (idx !== this._var.colAttrs.__orderIndex || this._var.refs.draggerCursor.style.display !== 'block') {
element = children[idx];
if (element == null) {
return;
}
this._var.colAttrs.__orderIndex = idx;
// avoid `offsetLeft` of hidden header to be 0
let left;
if (element.style.display === 'none') {
left = 0;
while (left === 0 && (element = children[++idx]) != null) {
left = element.offsetLeft;
}
if (!toLeft && left === 0) {
left = draggerCellLeft;
}
} else {
left = element.offsetLeft;
}
// set position of dragger cursor
this._var.refs.draggerCursor.style.cssText = `left: ${left - draggerCellLeft}px; display: block`;
}
}
/**
* @private
* @param {number} index
*/
_changeColumnOrder(index) {
this._var.refs.dragger.style.display = '';
this._var.refs.draggerCursor.style.display = '';
const orderIndex = this._var.colAttrs.__orderIndex;
if (orderIndex >= 0 && orderIndex !== index) {
let targetIndex = orderIndex - index;
if (targetIndex >= 0 && targetIndex <= 1) {
return;
}
const header = this._var.refs.header;
const children = this._headerCells;
const rows = this._tableRows;
const columns = this.columns;
const offset = this.expandable ? 1 : 0;
if (targetIndex > 1) {
targetIndex = orderIndex - 1;
// const current = columns[index];
// for (let i = index; i < targetIndex; ++i) {
// columns[i] = columns[i + 1];
// }
// columns[targetIndex] = current;
const current = columns.splice(index, 1)[0];
columns.splice(targetIndex, 0, current);
header.insertBefore(children[index], children[targetIndex].nextElementSibling);
for (let row of rows) {
row.insertBefore(row.children[index + offset], row.children[targetIndex + offset].nextElementSibling);
}
} else {
targetIndex = orderIndex;
// const current = columns[index];
// for (let i = index; i > targetIndex; i -= 1) {
// columns[i] = columns[i - 1];
// }
// columns[targetIndex] = current;
const current = columns.splice(index, 1)[0];
columns.splice(targetIndex, 0, current);
header.insertBefore(children[index], children[targetIndex]);
for (let row of rows) {
row.insertBefore(row.children[index + offset], row.children[targetIndex + offset]);
}
}
if (this.sortArray == null || this.sortArray.length === 0) {
// refresh sortIndex
[...children].forEach((th, i) => {
const arrow = th.querySelector('.arrow');
if (arrow == null) {
return;
}
if (arrow.className !== 'arrow') {
this.sortIndex = i;
}
});
}
if (typeof this.onColumnChanged === 'function') {
this.onColumnChanged(ColumnChangedType.Reorder, index, targetIndex);
}
}
}
/**
* @private
* @param {number} top
* @param {boolean} [reload]
* @returns {number}
*/
_scrollToTop(top, reload) {
const rowHeight = (this.rowHeight + 1);
top -= (top % (rowHeight * 2)) + (RedumCount * rowHeight);
if (top < 0) {
top = 0;
} else {
let bottomTop = this._var.containerHeight - (reload ? 0 : this._var.rowCount * rowHeight);
if (bottomTop < 0) {
bottomTop = 0;
}
if (top > bottomTop) {
top = bottomTop;
}
}
if (this._var.scrollTop !== top) {
this._var.scrollTop = top;
if (this.virtual) {
this._var.startIndex = top / rowHeight;
}
this.refresh();
if (this.virtual) {
this._var.refs.table.style.top = `${top}px`;
}
} else if (reload) {
this.refresh();
}
return top;
}
/**
* @private
* @param {string} key
* @param {("autoResize" | "style" | "resizing" | "dragging" | "filterSource" | "filterHeight" | "filterTop")} name
* @returns {any}
*/
_get(key, name) {
const attr = this._var.colAttrs[key];
if (attr == null) {
return null;
}
return attr[name];
}
/**
* @private
* @param {string} key
* @param {("autoResize" | "style" | "filterSource" | "filterHeight" | "filterTop")} name
* @param {any} value
*/
_set(key, name, value) {
const attr = this._var.colAttrs[key];
if (attr == null) {
this._var.colAttrs[key] = { [name]: value };
} else {
attr[name] = value;
}
}
/**
* @private
* @param {any} item
* @param {boolean} editing
* @param {GridColumnDefinition} col
* @returns {any}
*/
_getItemProp(item, editing, col) {
let value;
if (typeof col?.filter === 'function') {
value = col.filter(item, editing, this._var.refs.body);
} else {
value = item[col.key];
}
if (value == null) {
return value;
}
const prop = editing ? 'Value' : 'DisplayValue';
if (Object.prototype.hasOwnProperty.call(value, prop)) {
return value[prop];
}
return value;
}
/**
* @private
* @param {HTMLElement} target
* @returns {HTMLElement[]}
*/
_getRowTarget(target) {
let parent;
while ((parent = target.parentElement) != null && !parent.classList.contains('ui-grid-row')) {
target = parent;
}
return [parent, target];
}
/**
* @private
* @param {HTMLElement} element
* @returns {HTMLElement}
*/
_getParentElement(element) {
while (element != null && element.className !== 'ui-grid') {
element = element.parentElement;
}
return element;
}
/**
* @private
* @param {string} tagName
* @returns {boolean}
*/
_notHeader(tagName) {
return /^(input|label|layer|svg|use)$/i.test(tagName);
}
/**
* @private
* @param {MouseEvent} e
* @param {GridColumnDefinition} col
* @param {boolean} [force]
*/
_onHeaderClicked(e, col, force) {
if (!force && (this._get(col.key, 'resizing') || this._get(col.key, 'dragging'))) {
return;
}
if (!this._notHeader(e.target.tagName)) {
const index = this.columns.indexOf(col);
if (index < 0) {
return;
}
if (this.sortIndex === index) {
this.sortDirection = this.sortDirection === 1 ? -1 : 1;
} else {
this.sortIndex = index;
}
this.sortColumn();
if (typeof this.onColumnChanged === 'function') {
this.onColumnChanged(ColumnChangedType.Sort, index, this.sortDirection);
}
}
}
/**
* @private
* @param {MouseEvent} [e]
* @returns {boolean}
*/
_onCloseFilter(e) {
if (e != null) {
if ((e.target.tagName === 'LAYER' && e.target.classList.contains('filter')) ||
e.target.tagName === 'use') {
return false;
}
}
const panels = this._var.el.querySelectorAll('.filter-panel.active');
if (panels.length > 0) {
panels.forEach(el => el.classList.remove('active'));
setTimeout(() => this._var.el.querySelectorAll('.filter-panel').forEach(el => el.remove()), 120);
const filtering = this._var.colAttrs.__filtering;
if (filtering instanceof HTMLElement) {
filtering.classList.remove('hover');
}
delete this._var.colAttrs.__filtering;
document.removeEventListener('mousedown', this._onCloseFilter);
return true;
}
return false;
}
/**
* @private
* @param {MouseEvent} e
* @param {GridColumnDefinition} col
*/
_onFilter(e, col) {
if (this._onCloseFilter()) {
return;
}
document.addEventListener('mousedown', this._onCloseFilter.bind(this));
const panel = createElement('div', 'filter-panel');
panel.addEventListener('mousedown', e => e.stopPropagation());
const filter = e.currentTarget;
const th = filter.parentElement;
const width = th.offsetWidth;
panel.style.top = `${th.offsetHeight + this._var.el.scrollTop}px`;
const offsetLeft = th.offsetLeft;
const totalWidth = th.parentElement.offsetWidth;
const left = offsetLeft + FilterPanelWidth > totalWidth ?
totalWidth - FilterPanelWidth :
offsetLeft + (width > FilterPanelWidth ? width - FilterPanelWidth : 0);
panel.style.left = `${left}px`;
// search
let searchbox;
if (col.allowSearch !== false) {
const searchholder = createElement('div', 'filter-search-holder');
searchbox = createElement('input', 'filter-search-box ui-text');
searchbox.type = 'text';
const searchicon = createIcon('fa-regular', 'search');
searchicon.addEventListener('mousedown', e => {
searchbox.focus();
e.preventDefault();
});
searchholder.append(searchbox, searchicon);
panel.append(searchholder);
}
// list
const itemlist = createElement('div', 'filter-item-list');
itemlist.addEventListener('scroll', e => throttle(this._onFilterScroll, RefreshInterval, this, col, itemlist, e.target.scrollTop), { passive: true });
// - all
const itemall = createElement('div', 'filter-item filter-all');
itemall.appendChild(createCheckbox({
label: this.langs.all,
onchange: e => {
const checked = e.target.checked;
itemlist.querySelectorAll('.filter-content input').forEach(box => box.checked = checked);
for (let it of this._get(col.key, 'filterSource')) {
it.__checked = checked;
}
}
}));
itemlist.appendChild(itemall);
// - items
let array;
if (Array.isArray(col.filterSource)) {
array = col.filterSource;
} else if (typeof col.filterSource === 'function') {
array = col.filterSource.call(this, col);
} else {
const dict = Object.create(null);
for (let item of this._var.source) {
let displayValue = this._getItemProp(item.values, false, col);
if (displayValue == null) {
displayValue = col.filterAllowNull ? this.langs.null : '';
}
if (!Object.hasOwnProperty.call(dict, displayValue)) {
const val = this._getItemProp(item.values, true, col);
dict[displayValue] = {
Value: val,
DisplayValue: displayValue
};
}
}
array = Object.values(dict)
.sort((a, b) => {
// if (a == null && b == null) {
// return 0;
// }
// if (a == null && b != null) {
// return -1;
// }
// if (a != null && b == null) {
// return 1;
// }
// if (Object.prototype.hasOwnProperty.call(a, 'Value')) {
// a = a.Value;
// }
// if (Object.prototype.hasOwnProperty.call(b, 'Value')) {
// b = b.Value;
// }
// return a > b ? 1 : a < b ? -1 : 0;
return a.Value > b.Value ? 1 : a.Value < b.Value ? -1 : 0;
});
}
array = array.map(i => {
if (Object.prototype.hasOwnProperty.call(i, 'Value') &&
Object.prototype.hasOwnProperty.call(i, 'DisplayValue')) {
return i;
}
return {
Value: i,
DisplayValue: i == null ? this.langs.null : i
};
});
this._fillFilterList(col, itemlist, array, itemall);
itemall.querySelector('input').checked = ![...itemlist.querySelectorAll('.filter-content input')].some(i => !i.checked);
panel.appendChild(itemlist);
if (searchbox != null) {
searchbox.addEventListener('input', e => {
const key = e.currentTarget.value.toLowerCase();
const items = key.length === 0 ? array : array.filter(i => {
let displayValue;
if (i != null && Object.prototype.hasOwnProperty.call(i, 'DisplayValue')) {
displayValue = i.DisplayValue;
} else {
displayValue = i;
}
if (displayValue == null) {
displayValue = this.langs.null;
}
return String(displayValue).toLowerCase().includes(key);
});
this._fillFilterList(col, itemlist, items, itemall);
});
}
// function
const functions = createElement('div', 'filter-function');
functions.append(
createElement('button', ok => {
ok.innerText = this.langs.ok;
ok.addEventListener('click', () => {
const array = this._get(col.key, 'filterSource').filter(i => i.__checked !== false);
if (typeof col.onFilterOk === 'function') {
col.onFilterOk.call(this, col, array);
} else {
if (GridColumnTypeEnum.isCheckbox(col.type)) {
col.filterValues = array.map(a => a.Value);
} else {
const nullValue = col.filterAllowNull ? null : '';
col.filterValues = array.map(a => a.Value == null ? nullValue : a.DisplayValue);
}
}
this._var.colAttrs.__filtered = true;
this._refreshSource();
if (typeof col.onFiltered === 'function') {
col.onFiltered.call(this, col);
}
filter.classList.add('active');
this._onCloseFilter();
});
}),
createElement('button', reset => {
reset.innerText = this.langs.reset;
reset.addEventListener('click', () => {
delete col.filterValues;
this._var.colAttrs.__filtered = this.columns.some(c => c.filterValues != null)
this._refreshSource();
if (typeof col.onFiltered === 'function') {
col.onFiltered.call(this, col);
}
filter.classList.remove('active');
this._onCloseFilter();
});
})
);
panel.appendChild(functions);
this._var.el.appendChild(panel);
setTimeout(() => panel.classList.add('active'), 0);
this._var.colAttrs.__filtering = filter;
filter.classList.add('hover');
}
/**
* @private
* @param {GridColumnDefinition} col
* @param {HTMLDivElement} list
* @param {ValueItem[]} array
* @param {HTMLDivElement} all
*/
_fillFilterList(col, list, array, all) {
list.querySelector('.filter-holder')?.remove();
list.querySelector('.filter-content')?.remove();
const rowHeight = this.filterRowHeight;
const height = array.length * rowHeight;
this._set(col.key, 'filterHeight', height);
const holder = createElement('div', 'filter-holder');
holder.style.height = `${height}px`;
const content = createElement('div', 'filter-content');
content.style.top = `${rowHeight}px`;
this._set(col.key, 'filterSource', array);
const propKey = GridColumnTypeEnum.isCheckbox(col.type) ? 'Value' : 'DisplayValue';
const nullValue = col.filterAllowNull ? null : '';
for (let item of array) {
let v = item.Value ?? nullValue;
if (v != null) {
v = Object.prototype.hasOwnProperty.call(item, propKey) ? item[propKey] : item;
}
item.__checked = !Array.isArray(col.filterValues) || col.filterValues.includes(v);
}
if (array.length > 12) {
array = array.slice(0, 12);
}
this._doFillFilterList(content, array, all);
list.append(holder, content);
}
/**
* @private
* @param {HTMLDivElement} content
* @param {ValueItem[]} array
* @param {HTMLDivElement} all
*/
_doFillFilterList(content, array, all) {
for (let item of array) {
const div = createElement('div', 'filter-item');
div.appendChild(createCheckbox({
checked: item.__checked,
label: Object.prototype.hasOwnProperty.call(item, 'DisplayValue') ? item.DisplayValue : item,
onchange: e => {
item.__checked = e.target.checked;
all.querySelector('input').checked = ![...content.querySelectorAll('input')].some(i => !i.checked);
}
}));
content.appendChild(div);
}
}
/**
* @private
* @param {GridColumnDefinition} col
* @param {HTMLDivElement} list
* @param {number} top
*/
_onFilterScroll(col, list, top) {
const rowHeight = this.filterRowHeight;
top -= (top % (rowHeight * 2)) + rowHeight;
if (top < 0) {
top = 0;
} else {
let bottomTop = this._get(col.key, 'filterHeight') - (12 * rowHeight);
if (bottomTop < 0) {
bottomTop = 0;
}
if (top > bottomTop) {
top = bottomTop;
}
}
if (this._get(col.key, 'filterTop') !== top) {
this._set(col.key, 'filterTop', top);
const startIndex = top / rowHeight;
let array = this._get(col.key, 'filterSource');
if (startIndex + 12 < array.length) {
array = array.slice(startIndex, startIndex + 12);
} else {
array = array.slice(-12);
}
const content = list.querySelector('.filter-content');
content.replaceChildren();
this._doFillFilterList(content, array, list.querySelector('.filter-all'));
content.style.top = `${top + rowHeight}px`;
}
}
/**
* @private
* @param {MouseEvent} e
* @param {GridColumnDefinition} col
*/
_onDragStart(e, col) {
if (this._notHeader(e.target.tagName)) {
return;
}
if (e.currentTarget.classList.contains('sticky')) {
return;
}
const index = indexOfParent(e.currentTarget) - (this.expandable ? 1 : 0);
const cx = getClientX(e);
const window = this.window ?? global;
const clearEvents = attr => {
for (let event of ['mousemove', 'mouseup']) {
if (Object.prototype.hasOwnProperty.call(attr, event)) {
window.removeEventListener(event, attr[event]);
delete attr[event];
}
}
};
let attr = this._var.colAttrs[col.key];
if (attr == null) {
attr = this._var.colAttrs[col.key] = {};
} else {
clearEvents(attr);
}
attr.dragging = true;
const draggerCellLeft = this._var.refs.header.querySelector('th:last-child').offsetLeft;
// const gridScrollLeft = this._var.el.scrollLeft;
let p = this._var.el;
let gridLeftFromWindow = p.offsetLeft;
while ((p = p.offsetParent) != null) {
gridLeftFromWindow += p.offsetLeft + p.clientLeft;
}
const mouse = cx - e.currentTarget.offsetLeft + this._var.el.scrollLeft - gridLeftFromWindow;
const dragmove = e => {
const cx2 = getClientX(e);
const offset = cx2 - cx;
let pos = attr.offset;
let dragging;
if (pos == null) {
if (offset > MiniDragOffset || offset < -MiniDragOffset) {
dragging = true;
}
} else if (pos !== offset) {
dragging = true;
}
if (dragging) {
this._changingColumnOrder(index, offset, mouse, draggerCellLeft);
attr.offset = offset;
}
};
attr.mousemove = e => throttle(dragmove, RefreshInterval, this, e);
attr.mouseup = () => {
clearEvents(attr);
if (attr.offset == null) {
delete attr.dragging;
} else {
setTimeout(() => {
delete attr.dragging;
delete attr.offset;
});
this._changeColumnOrder(index);
}
};
['mousemove', 'mouseup'].forEach(event => window.addEventListener(event, attr[event]));
}
/**
* @private
* @param {MouseEvent} e
* @param {GridColumnDefinition} col
*/
_onResizeStart(e, col) {
const cx = getClientX(e);
const width = col.width;
const index = indexOfParent(e.currentTarget.parentElement) - (this.expandable ? 1 : 0);
const window = this.window ?? global;
const clearEvents = attr => {
for (let event of ['mousemove', 'mouseup']) {
if (Object.prototype.hasOwnProperty.call(attr, event)) {
window.removeEventListener(event, attr[event]);
delete attr[event];
}
}
};
let attr = this._var.colAttrs[col.key];
if (attr == null) {
attr = this._var.colAttrs[col.key] = {};
} else {
clearEvents(attr);
}
attr.resizing = width;
const resizemove = e => {
const cx2 = getClientX(e);
const val = width + (cx2 - cx);
if (val < MiniColumnWidth) {
return;
}
attr.resizing = val;
attr.sizing = true;
this._changeColumnWidth(index, val);
};
attr.mousemove = e => throttle(resizemove, RefreshInterval, this, e);
attr.mouseup = e => {
clearEvents(attr);
const width = attr.resizing;
if (width != null) {
setTimeout(() => delete attr.resizing);
if (attr.sizing) {
delete attr.sizing;
delete attr.autoResize;
this._changeColumnWidth(index, width);
if (typeof this.onColumnChanged === 'function') {
this.onColumnChanged(ColumnChangedType.Resize, index, width);
}
}
}
e.stopPropagation();
e.preventDefault();
};
['mousemove', 'mouseup'].forEach(event => window.addEventListener(event, attr[event]));
}
/**
* @private
* @param {MouseEvent} e
* @param {GridColumnDefinition} col
*/
_onAutoResize(e, col) {
const th = e.currentTarget.parentElement;
const index = indexOfParent(th);
const offset = this.expandable ? 1 : 0;
let width = th.querySelector('div:first-child').scrollWidth;
for (let row of this._tableRows) {
const element = row.children[index + offset].children[0];
const w = element.scrollWidth;
if (w > width) {
width = w;
}
}
if (width < MiniColumnWidth) {
width = MiniColumnWidth;
}
if (width > 0 && width !== col.width) {
width += 12;
this._changeColumnWidth(index - offset, width);
if (typeof this.onColumnChanged === 'function') {
this.onColumnChanged(ColumnChangedType.Resize, index - offset, width);
}
}
}
/**
* @private
* @param {GridColumnDefinition} col
* @param {boolean} flag
*/
_onColumnAllChecked(col, flag) {
if (this._var.currentSource == null) {
return;
}
const key = col.key;
const isFunction = typeof col.enabled === 'function';
const isString = typeof col.enabled === 'string';
if (typeof col.onAllChecked === 'function') {
col.onAllChecked.call(this, col, flag);
} else {
for (let row of this._var.currentSource) {
const item = row.values;
if (item == null) {
continue;
}
const enabled = isFunction ? col.enabled(item) : isString ? item[col.enabled] : col.enabled;
if (enabled !== false) {
item[key] = flag;
row.__changed = true;
if (typeof col.onChanged === 'function') {
col.onChanged.call(this, item, flag);
}
}
}
this.refresh();
}
}
/**
* @private
* @param {Event} e
*/
_onScroll(e) {
if (this._var.colAttrs.__filtering != null) {
this._onCloseFilter();
}
if (this.onBodyScrolled === 'function') {
this.onBodyScrolled(e);
}
if (!this.virtual) {
if (this.total != null) {
this._var.refs.footer.parentElement.style.bottom = `${this._var.footerOffset - e.target.scrollTop}px`;
}
return;
}
const top = e.target.scrollTop;
this._scrollToTop(top);
if (this.total != null) {
this._var.refs.footer.parentElement.style.bottom = `${this._var.refs.table.offsetTop + this._var.footerOffset - e.target.scrollTop}px`;
}
if (this._var.isFirefox) {
// 修复 firefox 下列头显示位置不正确的问题
debounce(this.refresh, RefreshInterval, this);
}
}
/**
* @private
* @param {MouseEvent} e
* @param {HTMLDivElement} holder
*/
_onGridMouseMove(e, holder) {
e.stopPropagation();
if (e.target.classList.contains('ui-grid-hover-holder')) {
return;
}
let [parent, target] = this._getRowTarget(e.target);
if (parent == null) {
delete holder.dataset.row;
delete holder.dataset.col;
if (holder.classList.contains('active')) {
holder.classList.remove('active');
}
return;
}
if (this._getParentElement(parent) !== this._var.el) {
// sub ui-grid
return;
}
const element = target.children[0];
if (element?.tagName !== 'SPAN') {
if (holder.classList.contains('active')) {
delete holder.dataset.row;
delete holder.dataset.col;
holder.classList.remove('active');
}
return;
}
const row = target.dataset.row;
const col = target.dataset.col;
if (holder.dataset.row === row &&
holder.dataset.col === col) {
return;
}
if (element.scrollWidth > element.offsetWidth) {
holder.dataset.row = row;
holder.dataset.col = col;
holder.innerText = element.innerText;
const top = (parent.classList.contains('ui-grid-total-row') ? this._var.refs.footer.parentElement.offsetTop + 1 : target.offsetTop) + this._var.refs.table.offsetTop;
let left = target.offsetLeft;
let width = holder.offsetWidth;
if (width > this._var.bodyClientWidth) {
width = this._var.bodyClientWidth;
}
const maxleft = this._var.bodyClientWidth + this._var.scrollLeft - width;
if (left > maxleft) {
left = maxleft;
}
const height = target.offsetHeight;
holder.style.cssText = `top: ${top}px; left: ${left}px; max-width: ${this._var.bodyClientWidth}px; height: ${height - 2}px`;
holder.classList.add('active');
} else if (holder.classList.contains('active')) {
delete holder.dataset.row;
delete holder.dataset.col;
holder.classList.remove('active');
}
}
/**
* @private
* @param {MouseEvent} e
* @param {number} index
* @param {number} colIndex
*/
_onRowClicked(e, index, colIndex) {
const startIndex = this._var.startIndex;
const selectedIndex = startIndex + index;
if (typeof this.willSelect === 'function' && !this.willSelect(selectedIndex, colIndex)) {
return;
}
// multi-select
let flag = false;
const selectedIndexes = this._var.selectedIndexes;
if (this.multiSelect) {
if (e.ctrlKey) {
const i = selectedIndexes.indexOf(selectedIndex);
if (i < 0) {
selectedIndexes.push(selectedIndex);
} else {
selectedIndexes.splice(i, 1);
}
flag = true;
} else if (e.shiftKey && selectedIndexes.length > 0) {
if (selectedIndexes.length > 1 || selectedIndexes[0] !== selectedIndex) {
let start = selectedIndexes[selectedIndexes.length - 1];
let end;
if (start > selectedIndex) {
end = start;
start = selectedIndex;
} else {
end = selectedIndex;
}
selectedIndexes.splice(0);
for (let i = start; i <= end; ++i) {
selectedIndexes.push(i);
}
flag = true;
}
}
}
if (!flag && (selectedIndexes.length !== 1 || selectedIndexes[0] !== selectedIndex)) {
selectedIndexes.splice(0, selectedIndexes.length, selectedIndex);
flag = true;
}
// apply style
if (flag) {
if (this.readonly) {
this._tableRows.forEach((row, i) => {
if (selectedIndexes.includes(startIndex + i)) {
row.classList.add('selected');
} else if (row.classList.contains('selected')) {
row.classList.remove('selected');
}
});
} else {
this.refresh();
}
if (typeof this.onSelectedRowChanged === 'function') {
this.onSelectedRowChanged(selectedIndex);
}
}
this._var.selectedColumnIndex = colIndex;
if ((this.fullrowClick || colIndex >= 0) && e.buttons === 1 && typeof this.cellClicked === 'function') {
if (this.cellClicked(selectedIndex, colIndex) === false) {
e.stopPropagation();
e.preventDefault();
}
}
}
/**
* @private
* @param {MouseEvent} e
*/
_onRowDblClicked(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'LAYER' && e.target.className === 'ui-check-inner' || e.target.tagName === 'LABEL' && (e.target.className === 'ui-drop-text' || e.target.className === 'ui-drop-caret')) {
return;
}
const index = this.selectedIndex;
if (typeof this.onRowDblClicked === 'function') {
this.onRowDblClicked(index);
}
if (typeof this.onCellDblClicked === 'function') {
const colIndex = this._var.selectedColumnIndex;
if (this.fullrowClick || colIndex >= 0) {
this.onCellDblClicked(index, colIndex);
}
}
}
/**
* @private
* @param {any} e
* @param {number} index
* @param {GridColumnDefinition} col
* @param {any} value
* @param {HTMLTableCellElement} cell
* @param {boolean} [blur]
*/
_onRowChanged(e, index, col, value, cell, blur) {
if (this._var.currentSource == null) {
return;
}
const row = this._var.currentSource[this._var.startIndex + index];
delete row.source;
const item = row.values;
if (item == null) {
return;
}
let enabled = col.enabled;
if (typeof enabled === 'function') {
enabled = enabled.call(col, item);
} else if (typeof enabled === 'string') {
enabled = item[enabled];
}
if (enabled !== false) {
const val = item[col.key];
let oldValue;
if (val != null && Object.prototype.hasOwnProperty.call(val, 'Value')) {
oldValue = val.Value;
val.Value = value;
} else {
oldValue = val;
item[col.key] = value;
}
let tip = col.tooltip;
if (typeof tip === 'function') {
tip = tip.call(col, item);
}
if (nullOrEmpty(tip)) {
cell.querySelector('.ui-tooltip-wrapper')?.remove();
} else {
setTooltip(cell.children[0], tip, false, this.element);
}
row.__changed = true;
if (blur) {
if (typeof col.onInputEnded === 'function') {
col.onInputEnded.call(this, item, value);
}
} else {
if (typeof col.onChanged === 'function') {
col.onChanged.call(this, item, value, oldValue, e);
}
}
}
}
/**
* @private
* @param {MouseEvent} _e
* @param {number} index
* @param {HTMLTableRowElement} _row
*/
_onExpandable(_e, index, _row) {
if (this._var.currentSource == null) {
return;
}
const vals = this._var.currentSource[this._var.startIndex + index];
vals.__expanded = !vals.__expanded
this.refresh();
}
}