From 5d199a2bfb27a782a6eff317d48c896810432132 Mon Sep 17 00:00:00 2001 From: Tsanie Date: Thu, 1 Feb 2024 17:29:30 +0800 Subject: [PATCH] feature: expandable rows add: comments --- .gitignore | 3 +- lib/ui/css/grid.scss | 17 ++ lib/ui/date.js | 2 +- lib/ui/grid/column.js | 54 ++-- lib/ui/grid/grid.js | 609 ++++++++++++++++++++++++++++++++++++------ 5 files changed, 574 insertions(+), 111 deletions(-) diff --git a/.gitignore b/.gitignore index 81b96d2..089997b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ dist-ssr desktop.ini # User definition -docs \ No newline at end of file +docs +jsdoc diff --git a/lib/ui/css/grid.scss b/lib/ui/css/grid.scss index eb08683..4ad6e27 100644 --- a/lib/ui/css/grid.scss +++ b/lib/ui/css/grid.scss @@ -283,6 +283,22 @@ background-color: var(--row-bg-color); } + &.ui-expandable { + &>svg { + width: 24px; + height: 34px; + padding: 8px 3px; + box-sizing: border-box; + display: block; + cursor: pointer; + transition: opacity .12s ease; + + &:hover { + opacity: .4; + } + } + } + >span { padding: var(--spacing-cell); display: block; @@ -402,6 +418,7 @@ white-space: pre; display: flex; align-items: center; + overflow: hidden; visibility: hidden; opacity: 0; transition: visibility 0s linear .12s, opacity .12s ease; diff --git a/lib/ui/date.js b/lib/ui/date.js index aeea3fa..efc3d0b 100644 --- a/lib/ui/date.js +++ b/lib/ui/date.js @@ -261,7 +261,7 @@ export class DateSelector { * @static * @param {HTMLElement} [dom=document.body] 父元素 * @param {Function} [trigger] 日期设置事件触发函数 - * @param {Date} trigger.[this]] - 上下文为触发设置日期的 `DateSelector` 实例 + * @param {Date} trigger.{this} - 上下文为触发设置日期的 `DateSelector` 实例 * @param {Date} trigger.date - 修改后的日期值 * @example HTML * diff --git a/lib/ui/grid/column.js b/lib/ui/grid/column.js index 7af4691..0551703 100644 --- a/lib/ui/grid/column.js +++ b/lib/ui/grid/column.js @@ -12,6 +12,17 @@ import { createDateInput, formatDate, setDateValue, getDateValue } from "../date * @class */ export class GridColumn { + /** + * 设置该类型是否支持触发 {@linkcode GridColumnDefinition}`.onInputEnded` 方法
+ * 该属性返回 `true` 后,在任意事件中修改行包装对象的 `__editing` 值,则会在行列元素变动时及时触发 [onInputEnded]{@linkcode GridColumnDefinition#onInputEnded} 方法,避免例如文本框还未触发 `onchange` 事件就被移除元素而导致的问题 + * + * 更多例子参考代码中 {@linkcode GridInputColumn} 的实现 + * @member + * @name GridColumn.editing + * @readonly + * @type {boolean} + */ + /** * 创建显示单元格时调用的方法 * @param {GridColumnDefinition} col - 列定义对象 @@ -27,6 +38,8 @@ export class GridColumn { * * 元素修改后设置行包装对象的 `__editing` 后,支持在离开编辑状态时及时触发 [leaveEdit]{@linkcode GridColumn.leaveEdit} 方法
* 更多例子参考代码中 {@linkcode GridDropdownColumn} 的实现。 + * @method + * @name GridColumn.createEdit * @param {Function} trigger - 编辑事件回调函数,`e` 参数会传递给 [getValue]{@linkcode GridColumn.getValue} 方法 * @param {GridColumnDefinition} col - 列定义对象 * @param {HTMLElement} container - 父容器元素 @@ -34,15 +47,25 @@ export class GridColumn { * @returns {HTMLElement} 返回创建的编辑状态的单元格元素 * @virtual */ - static createEdit() { } /** * 创建列头时调用的方法 + * @method + * @name GridColumn.createCaption * @param {GridColumnDefinition} col - 列定义对象 * @returns {HTMLElement} 返回创建的列头元素 * @virtual */ - static createCaption() { } + + /** + * 获取编辑状态单元格值时调用的方法 + * @method + * @name GridColumn.getValue + * @param {any} `e` 由 [createEdit]{@linkcode GridColumn.createEdit} 方法中 `trigger` 函数传递来的对象 + * @param {GridColumnDefinition} col - 列定义对象 + * @returns {(string | boolean | number)} 返回单元格的值 + * @virtual + */ /** * 设置单元格值时调用的方法 @@ -57,15 +80,6 @@ export class GridColumn { element.innerText = val; } - /** - * 获取编辑状态单元格值时调用的方法 - * @param {any} `e` 由 [createEdit]{@linkcode GridColumn.createEdit} 方法中 `trigger` 函数传递来的对象 - * @param {GridColumnDefinition} col - 列定义对象 - * @returns {(string | boolean | number)} 返回单元格的值 - * @virtual - */ - static getValue() { } - /** * 设置单元格样式时调用的方法 * @param {HTMLElement} element - 单元格元素 @@ -94,27 +108,22 @@ export class GridColumn { /** * 单元格离开编辑元素时触发,需要由行包装对象的 `__editing` 来确定是否触发。 + * @method + * @name GridColumn.leaveEdit * @param {HTMLElement} element - 单元格元素 * @param {HTMLElement} container - 父容器元素 * @virtual */ - static leaveEdit() { } static toString() { return '[object Column]' } } /** - * 单行文本列 + * 单行文本输入列 + * @class * @extends GridColumn */ export class GridInputColumn extends GridColumn { - /** - * 设置该类型是否支持触发 [onInputEnded]{@linkcode GridColumnDefinition.onInputEnded} 方法
- * 该属性返回 `true` 后,在任意事件中修改行包装对象的 `__editing` 值,则会在行列元素变动时及时触发 [onInputEnded]{@linkcode GridColumnDefinition.onInputEnded} 方法,避免例如文本框还未触发 `onchange` 事件就被移除元素而导致的问题
- * 更多例子参考代码中 {@linkcode GridInputColumn} 的实现 - * @readonly - * @type {boolean} - */ static get editing() { return true }; static createEdit(trigger, col, _wrapper, vals) { @@ -185,6 +194,11 @@ export class GridTextColumn extends GridInputColumn { const SymbolDropdown = Symbol.for('ui-dropdown'); +/** + * 下拉选择列 + * @class + * @extends GridColumn + */ export class GridDropdownColumn extends GridColumn { static createEdit(trigger, col, container, it) { const drop = new Dropdown({ diff --git a/lib/ui/grid/grid.js b/lib/ui/grid/grid.js index 9a34c85..3790c84 100644 --- a/lib/ui/grid/grid.js +++ b/lib/ui/grid/grid.js @@ -15,12 +15,13 @@ const ColumnChangedType = { Resize: 'resize', Sort: 'sort' }; -const RefreshInterval = isMobile() ? 32 : 0; +const RefreshInterval = isMobile() ? 32 : 10; const HoverInternal = 200; const RedumCount = 4; const MiniDragOffset = 10; const MiniColumnWidth = 50; const FilterPanelWidth = 200; +const ExpandableWidth = 24; function getClientX(e) { if (e == null) { @@ -58,9 +59,9 @@ let r = lang; * 行数据包装接口 * @typedef GridItemWrapper * @property {object} values - 真实数据对象 - * @property {(GridItem | any)} values.[key]] 数据属性 + * @property {(GridItem | any)} values.{key} 数据属性 * @property {object} source - 下拉数据源缓存对象 - * @property {GridSourceItem[]} source.[key]] 数据源 + * @property {GridSourceItem[]} source.{key} 数据源 */ /** @@ -84,6 +85,7 @@ let r = lang; * @param {GridItem} item - 行数据对象 * @param {boolean} editing - 是否处于编辑状态 * @param {HTMLElement} [body] - Grid 控件的 `` 部分 + * @returns {any} - 返回过滤后的显示或编辑值 * @this GridColumnDefinition */ @@ -175,11 +177,46 @@ let r = lang; * @this GridColumnDefinition */ +/** + * 下拉列表参数对象 + * @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} 返回格式化后的结果 + */ + /** * 列定义接口 * @typedef {object} GridColumnDefinition * @property {string} key - 列关键字,默认以该关键字从行数据中提取单元格值,行数据的关键字属性值里包含 DisplayValue 则优先显示此值 - * @property {(GridColumnTypeEnum | GridColumn)} [type=Grid.ColumnTypes.Common] - 列的类型,可以为 {@linkcode GridColumn} 的子类,或者内置类型 {@linkcode GridColumnTypeEnum} + * @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 {object} [captionStyle] - 列标题的元素样式 * @property {number} [width] - 大于 0 则设置为该宽度,否则根据列内容自动调整列宽 @@ -188,8 +225,16 @@ let r = lang; * * * `boolean` 则直接使用该值 * * `string` 则以该值为关键字从行数据中取值作为判断条件 - * * `(item: GridItem) => boolean` 则调用该函数(上下文为列定义对象),以返回值作为判断条件 + * * `GridItemBooleanCallback` 则调用如下回调,以返回值作为判断条件 + * @property {GridColumnDefinition} enabled.{this} - 上下文为列定义对象 + * @property {GridItem} enabled.item - 行数据对象 + * @property {boolean} enabled.{returns} - 返回是否启用 * @property {GridItemFilterCallback} [filter] - 单元格取值采用该函数返回的值 + * @property {GridColumnDefinition} filter.{this} - 上下文为列定义对象 + * @property {GridItem} filter.item - 行数据对象 + * @property {boolean} filter.editing - 是否处于编辑状态 + * @property {HTMLElement} [filter.body] - Grid 控件的 `` 部分 + * @property {any} filter.{returns} - 返回过滤后的显示或编辑值 * @property {string} [text] - 单元格以该值填充内容,忽略filter与关键字属性 * @property {boolean} [visible=true] - 列是否可见 * @property {boolean} [resizable=true] - 列是否允许调整宽度 @@ -198,30 +243,72 @@ let r = lang; * @property {boolean} [allcheck=false] - 列为复选框类型时是否在列头增加全选复选框 * @property {object} [css] - 单元格css样式对象(仅在重建行元素时读取) * @property {GridItemObjectCallback} [styleFilter] - 根据返回值填充单元格样式(填充行列数据时读取) + * @property {GridColumnDefinition} styleFilter.{this} - 上下文为列定义对象 + * @property {GridItem} styleFilter.item - 行数据对象 + * @property {object} styleFilter.{returns} - 返回样式对象 * @property {GridItemStringCallback} [bgFilter] - 根据返回值设置单元格背景色 + * @property {GridColumnDefinition} bgFilter.{this} - 上下文为列定义对象 + * @property {GridItem} bgFilter.item - 行数据对象 + * @property {string} bgFilter.{returns} - 返回单元格背景色字符串 * @property {object} [events] - 给单元格元素附加事件(事件函数上下文为数据行对象) - * @property {Function} events.[event]] - 事件回调函数 - * @property {(object | GridItemObjectCallback)} [attrs] - 根据返回值设置单元格元素的附加属性,允许直接设置对象也支持函数返回对象 + * @property {Function} events.{event} - 事件回调函数 + * @property {(object | GridItemObjectCallback)} [attrs] - 根据返回值设置单元格元素的附加属性,允许直接设置对象也支持调用如下函数返回对象 + * @property {GridColumnDefinition} attrs.{this} - 上下文为列定义对象 + * @property {GridItem} attrs.item - 行数据对象 + * @property {object} attrs.{returns} - 返回附加属性对象 * @property {boolean} [allowFilter=false] - 是否允许进行列头过滤 - * @property {(GridItem[] | GridColumnFilterSourceCallback)} [filterSource] - 自定义列过滤器的数据源(函数上下文为Grid) + * @property {(GridItem[] | GridColumnFilterSourceCallback)} [filterSource] - 自定义列过滤器的数据源,允许调用如下函数 + * @property {Grid} filterSource.{this} - 上下文为 Grid + * @property {GridColumnDefinition} filterSource.col - 列定义对象 + * @property {GridItem[]} filterSource.{returns} - 返回过滤器的数据数组 * @property {GridItemSortCallback} [sortFilter] - 自定义列排序函数 + * @property {GridItem} sortFilter.a - 对比行数据1 + * @property {GridItem} sortFilter.b - 对比行数据2 + * @property {number} sortFilter.{returns} - 返回大小对比结果 * @property {DropdownOptions} [dropOptions] - 列为下拉列表类型时以该值设置下拉框的参数 - * @property {(GridSourceItem[] | GridDropdownSourceCallback | Promise)} [source] - 列为下拉列表类型时以该值设置下拉列表数据源,支持函数返回,也支持返回异步对象 + * @property {(GridSourceItem[] | Promise | GridDropdownSourceCallback)} [source] - 列为下拉列表类型时以该值设置下拉列表数据源,支持返回异步对象,也支持如下函数返回 + * @property {GridItem} source.item - 行数据对象 + * @property {GridSourceItem[]} source.{returns} - 返回行下拉列表数据源 * @property {boolean} [sourceCache=false] - 下拉列表数据源是否缓存结果(即行数据未发生变化时仅从source属性获取一次值) - * @property {("fa-light" | "fa-regular" | "fa-solid")} [iconType=fa-light] - 列为图标类型时以该值设置图标样式(函数上下文为列定义对象) - * @property {(string | GridItemStringCallback)} [iconClassName] - 列为图标类型时以该值作为单元格元素的额外样式类型(函数上下文为列定义对象) + * @property {("fa-light" | "fa-regular" | "fa-solid")} [iconType=fa-light] - 列为图标类型时以该值设置图标样式 + * @property {(string | GridItemStringCallback)} [iconClassName] - 列为图标类型时以该值作为单元格元素的额外样式类型,支持直接使用字符串或者调用如下函数 + * @property {GridColumnDefinition} iconClassName.{this} - 上下文为列定义对象 + * @property {GridItem} iconClassName.item - 行数据对象 + * @property {string} iconClassName.{returns} - 返回额外样式类型字符串 * @property {string} [dateMin] - 列为日期类型时以该值作为最小可选日期值 * @property {string} [dateMax] - 列为日期类型时以该值作为最大可选日期值 * @property {DateFormatterCallback} [dateValueFormatter] - 列为日期类型时自定义日期格式化函数 - * @property {(string | GridItemStringCallback)} [tooltip] - 以返回值额外设置单元格的tooltip(函数上下文为列定义对象) + * @property {Date} dateValueFormatter.date - 日期值 + * @property {any} dateValueFormatter.{returns} - 返回格式化后的结果 + * @property {(string | GridItemStringCallback)} [tooltip] - 额外设置单元格的 tooltip,支持直接使用字符串或者调用如下函数 + * @property {GridColumnDefinition} tooltip.{this} - 上下文为列定义对象 + * @property {GridItem} tooltip.item - 行数据对象 + * @property {string} tooltip.{returns} - 返回额外 tooltip 字符串 * @property {GridColumnCheckedCallback} [onAllChecked] - 列头复选框改变时触发 + * @property {Grid} onAllChecked.{this} - 上下文为 Grid * @property {GridColumnDefinition} onAllChecked.col - 列定义对象 * @property {boolean} onAllChecked.flag - 是否选中 * @property {GridCellChangedCallback} [onChanged] - 单元格发生变化时触发 + * @property {Grid} onChanged.{this} - 上下文为 Grid + * @property {GridItem} onChanged.item - 行数据对象 + * @property {(boolean | string | number)} onChanged.value - 修改后的值 + * @property {(boolean | string | number)} onChanged.oldValue - 修改前的值 + * @property {any} [onChanged.e] - 列修改事件传递过来的任意对象 * @property {GridCellInputEndedCallback} [onInputEnded] - 文本单元格在输入完成时触发的事件 + * @property {Grid} onInputEnded.{this} - 上下文为 Grid + * @property {GridColumnDefinition} onInputEnded.col - 列定义对象 + * @property {string} onInputEnded.value - 修改后的文本框值 * @property {GridColumnFilterOkCallback} [onFilterOk] - 列过滤点击 `OK` 时触发的事件 + * @property {Grid} onFilterOk.{this} - 上下文为 Grid + * @property {GridColumnDefinition} onFilterOk.col - 列定义对象 + * @property {GridItem[]} onFilterOk.selected - 选中的过滤项 * @property {GridColumnFilteredCallback} [onFiltered] - 列过滤后触发的事件 + * @property {Grid} onFiltered.{this} - 上下文为 Grid + * @property {GridColumnDefinition} onFiltered.col - 列定义对象 * @property {GridColumnDropExpandedCallback} [onDropExpanded] - 列为下拉框类型时在下拉列表展开时触发的事件 + * @property {GridColumnDefinition} onDropExpanded.{this} - 上下文为列定义对象 + * @property {GridItem} onDropExpanded.item - 行数据对象 + * @property {Dropdown} onDropExpanded.drop - 拉框对象 */ /** @@ -275,6 +362,20 @@ const GridColumnDirection = { * @property {("asc" | "desc")} order - 升序或降序 */ +/** + * 扩展行对象接口 + * @typedef GridExpandableObject + * @property {HTMLElement} element - 扩展行元素 + */ + +/** + * 扩展行生成回调函数 + * @callback GridExpandableObjectCallback + * @param {GridItem} item - 行数据对象 + * @returns {GridExpandableObject} 返回扩展行对象 + * @this Grid + */ + /** * Grid 控件基础类 * @@ -324,30 +425,168 @@ const GridColumnDirection = { */ export class Grid { _var = { + /** + * 父容器元素 + * @type {HTMLElement} + */ + parent: null, + /** + * Grid 元素 - `div.ui-grid` + * @type {HTMLDivElement} + */ + el: null, + /** + * 全部数据数组 + * @type {GridItemWrapper[]} + */ + source: null, + /** + * 当前已过滤显示的数据数组 + * @type {GridItemWrapper[]} + */ + currentSource: null, + /** + * 当前选中的列索引 + * @type {number} + */ selectedColumnIndex: -1, + /** + * 当前选中的行索引数组 + * @type {number[]} + */ + selectedIndexes: null, + /** + * 虚模式头部索引 + * @type {number} + */ startIndex: 0, + /** + * 旧选择索引数组 + * @type {number[]} + */ + oldSelectedIndexes: null, + /** + * 旧虚模式头部索引 + */ + oldIndex: null, + /** + * 当前滚动上边距 + * @type {number} + */ + scrollTop: 0, + /** + * 当前滚动左边距 + * @type {number} + */ + scrollLeft: 0, + /** + * 一页高度可显示的行数 + * @type {number} + */ rowCount: -1, + /** + * 列类型缓存字典 + * @property {GridColumn} {key} - 关键字对应列的类型缓存对象 + */ colTypes: {}, - colAttrs: {} + /** + * 列属性字典 + * @property {object} {key} - 关键字对应列的拖拽、调整大小暂存对象 + * @property {boolean} {key}.dragging - 列正在拖拽 + * @property {number} {key}.offset - 列拖拽偏移 + * @property {Function} {key}.mousemove - 拖拽或调整大小移动回调函数 + * @property {Function} {key}.mouseup - 拖拽或调整大小鼠标释放回调函数 + * @property {number} {key}.resizing - 列临时大小 + * @property {boolean} {key}.sizing - 列已进入修改大小的状态 + * @property {boolean} {key}.autoResize - 列需要自动调整大小 + * @property {object} {key}.style - 列样式对象 + * @property {GridItem[]} {key}.filterSource - 列过滤面板数据源 + * @property {number} {key}.filterHeight - 列过滤面板高度 + * @property {number} {key}.filterTop - 列过滤面板滚动头部间距 + */ + colAttrs: { + /** + * 有已过滤的列 + * @type {boolean} + */ + __filtered: false, + /** + * 过滤面板已打开 + * @type {boolean} + */ + __filtering: false, + /** + * 上一个目标排序列索引 + * @type {number} + */ + __orderIndex: -1, + }, + /** + * 是否处于渲染中 + * @type {boolean} + */ + rendering: false, + /** + * 正文高度 + * @type {number} + */ + containerHeight: null, + /** + * 正文宽度 + * @type {number} + */ + bodyClientWidth: null, + /** + * 是否需要 resize + * @type {boolean} + */ + needResize: null, + /** + * 页面元素引用 + */ + refs: { + /** + * 表格引用 - table.ui-grid-table + * @type {HTMLTableElement} + */ + table: null, + /** + * 表格正文引用 - tbody + * @type {HTMLTableSectionElement} + */ + body: null, + /** + * 表格头部引用 - thead>tr + * @type {HTMLTableSectionElement} + */ + header: null, + /** + * 加载状态元素引用 - div.ui-grid-loading + * @type {HTMLDivElement} + */ + loading: null, + /** + * 大小计算元素引用 - span.ui-grid-sizer + * @type {HTMLSpanElement} + */ + sizer: null, + /** + * 包装元素引用 - div.ui-grid-wrapper + * @type {HTMLDivElement} + */ + wrapper: null, + /** + * 拖拽块引用 - div.dragger + * @type {HTMLDivElement} + */ + dragger: null, + /** + * 拖拽光标引用 - layer.dragger-cursor + * @type {HTMLElement} + */ + draggerCursor: null, + } }; - // _var.source; - // _var.currentSource; - // _var.parent; - // _var.el; - // _var.refs; - // _var.rendering; - // _var.selectedColumnIndex = -1; - // _var.selectedIndexes; - // _var.startIndex = 0; - // _var.needResize; - // _var.containerHeight; - // _var.bodyClientWidth; - // _var.rowCount = -1; - // _var.scrollTop; - // _var.scrollLeft; - // _var.colTypes = {}; - // _var.colAttrs = {}; - // _var.vtable = []; /** * 列定义的数组 @@ -460,8 +699,26 @@ export class Grid { * 排序列数组 * @type {GridColumnSortDefinition[]} * @default null + * @property {string} column - 排序列的关键字 + * @property {("asc" | "desc")} order - 升序或降序 */ sortArray = null; + /** + * 是否支持点击扩展 + * @type {boolean} + * @default false + */ + expandable; + /** + * 扩展行生成器 + * @type {GridExpandableObjectCallback} + * @property {Grid} {this} - 上下文为 Grid + * @property {GridItem} item - 行数据对象 + * @property {GridExpandableObject} {returns} - 返回扩展行对象 + * @property {number} {returns}.index - 行索引 + * @property {HTMLElement} {returns}.element - 扩展行元素 + */ + expandableGenerator; /** * 即将选中行时触发 @@ -469,6 +726,7 @@ export class Grid { * @param {number} index - 即将选中的行索引 * @param {number} colIndex - 即将选中的列索引 * @returns {boolean} 返回 `false`、`null`、`undefined`、`0` 等则取消选中动作 + * @this Grid */ willSelect; /** @@ -477,6 +735,7 @@ export class Grid { * @param {number} index - 点击的行索引 * @param {number} colIndex - 点击的列索引 * @returns {boolean} 返回 false 则取消事件冒泡 + * @this Grid */ cellClicked; @@ -484,6 +743,7 @@ export class Grid { * 选中行发生变化时触发的事件 * @event * @param {number} index - 选中的行索引 + * @this Grid */ onSelectedRowChanged; /** @@ -491,12 +751,14 @@ export class Grid { * @event * @param {number} index - 双击的行索引 * @param {number} colIndex - 双击的列索引 + * @this Grid */ onCellDblClicked; /** * 行双击时触发的事件 * @event * @param {number} index - 双击的行索引 + * @this Grid */ onRowDblClicked; /** @@ -509,31 +771,56 @@ export class Grid { * * "sort" 为发生列排序事件,此时 value 为 1(升序)或 -1(倒序) * @param {number} colIndex - 发生变化事件的列索引 * @param {number | GridColumnDirection} value - 变化的值 + * @this Grid */ onColumnChanged; /** * 列滚动时触发的事件 * @event * @param {Event} e - 滚动事件对象 + * @this Grid */ onBodyScrolled; + /** + * 扩展行展开时触发的事件 + * @event + * @param {GridItem} item - 行数据对象 + * @param {GridExpandableObject} expandableObject - 由 [expandableGenerator]{@linkcode Grid#expandableGenerator} 产生的行扩展对象 + * @param {HTMLElement} expandableObject.element - 扩展行元素 + * @this Grid + */ + onRowExpanded; + /** + * 扩展行收缩时触发的事件 + * @event + * @param {GridItem} 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 } /** - * - * @param {(string | HTMLElement)?} container Grid 控件所在的父容器,可以是 string 表示选择器,也可以是 HTMLElement 对象 - * Grid 控件构造函数
- * _**构造时可以不进行赋值,但是调用 init 函数时则必须进行赋值**_ + * Grid 控件构造函数 + * @param {(string | HTMLElement)?} container Grid 控件所在的父容器,可以是 string 表示选择器,也可以是 HTMLElement 对象
_**构造时可以不进行赋值,但是调用 init 函数时则必须进行赋值**_ * @param {Function} [getText] 获取多语言文本的函数代理 * @param {string} getText.id - 资源 ID * @param {string} [getText.def] - 默认资源 - * @param {string} getText.[returns]] 返回的多语言 + * @param {string} getText.{returns} 返回的多语言 */ constructor(container, getText) { this._var.parent = typeof container === 'string' ? document.querySelector(container) : container; @@ -589,6 +876,8 @@ export class Grid { /** * 获取已过滤的数据数组,或者设置数据并刷新列表 * @type {GridItem[]} + * @property {any} Value - 值 + * @property {string} DisplayValue - 显示值 */ get source() { return this._var.currentSource?.map(s => s.values) } set source(list) { @@ -608,6 +897,13 @@ export class Grid { this._refreshSource(list); } + /** + * 获取已过滤的数据中的扩展对象数组 + * @type {GridExpandableObject[]} + * @property {HTMLElement} element - 扩展行元素 + */ + get sourceExpandable() { return this._var.currentSource?.map(s => s.__expandable_object) } + /** * 设置单行数据 * @param {number} index - 行索引 @@ -840,9 +1136,13 @@ export class Grid { return this.columns[this.sortIndex]?.key; } - get tableRows() { + get _tableRows() { // return [...this._var.refs.body.children]; - return Array.prototype.slice.call(this._var.refs.body.children); + return Array.prototype.slice.call(this._var.refs.body.querySelectorAll('&>.ui-grid-row')); + } + + get _headerCells() { + return Array.prototype.slice.call(this._var.refs.header.querySelectorAll('&>th.column')); } /** @@ -856,7 +1156,7 @@ export class Grid { if (this.readonly !== true) { this.refresh(); } else { - this.tableRows.forEach((row, i) => { + this._tableRows.forEach((row, i) => { if (indexes.includes(startIndex + i)) { row.classList.add('selected'); } else if (row.classList.contains('selected')) { @@ -955,6 +1255,38 @@ export class Grid { 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 !== true) { + this.refresh(); + } else { + this._tableRows.forEach(row => { + row.classList.remove('selected'); + }); + } + if (typeof this.onSelectedRowChanged === 'function') { + this.onSelectedRowChanged(selectedIndex); + } + this._var.selectedColumnIndex = -1; + return; + } + let [parent, target] = this._getRowTarget(e.target); + if (parent == null) { + return; + } + const rowIndex = 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); @@ -969,7 +1301,7 @@ export class Grid { const table = createElement('table', 'ui-grid-table'); this._var.refs.table = table; this._createHeader(table); - const body = this._createBody(table); + this._createBody(table); wrapper.appendChild(table); // tooltip if (!this.tooltipDisabled) { @@ -985,7 +1317,7 @@ export class Grid { }); holder.addEventListener('dblclick', e => this._onRowDblClicked(e)); wrapper.appendChild(holder); - body.addEventListener('mousemove', e => throttle(this._onBodyMouseMove, HoverInternal, this, e, holder), { passive: true }); + wrapper.addEventListener('mousemove', e => throttle(this._onGridMouseMove, HoverInternal, this, e, holder), { passive: true }); } // loading @@ -1057,7 +1389,7 @@ export class Grid { const filtered = this.columns.some(c => c.filterValues != null); if ((filtered ^ this._var.colAttrs.__filtered) === 1) { this._var.colAttrs.__filtered = filtered; - const headers = this._var.refs.header.children; + const headers = this._headerCells; for (let i = 0; i < this.columns.length; ++i) { const ele = headers[i].querySelector('.filter'); if (ele == null) { @@ -1076,7 +1408,7 @@ export class Grid { if (this.extraRows > 0) { length += this.extraRows; } - this._var.containerHeight = length * (this.rowHeight + 1); + this._var.containerHeight = (length + 1) * (this.rowHeight + 1); if (!keep) { this._var.el.scrollTop = 0; // this._var.el.scrollLeft = 0; @@ -1095,7 +1427,7 @@ export class Grid { throw new Error('body has not been created.'); } const widths = {}; - this._fillRows(this.tableRows, this.columns, widths); + this._fillRows(this._tableRows, this.columns, widths); if (this._var.needResize && widths.flag) { this._var.needResize = false; this.columns.forEach((col, i) => { @@ -1161,7 +1493,7 @@ export class Grid { } this.sortArray = null; const direction = this.sortDirection; - [...this._var.refs.header.children].forEach((th, i) => { + [...this._headerCells].forEach((th, i) => { const arrow = th.querySelector('.arrow'); if (arrow == null) { return; @@ -1223,7 +1555,7 @@ export class Grid { this.refresh(); } // arrow icon - [...this._var.refs.header.children].forEach((th, i) => { + [...this._headerCells].forEach((th, i) => { const arrow = th.querySelector('.arrow'); if (arrow == null) { return; @@ -1263,7 +1595,7 @@ export class Grid { grid.onSelectedRowChanged = rowChanged; const reload = index => { grid.selectedIndexes = [index]; - grid.scrollTop = index * grid.rowHeight; + grid.scrollTop = index * (grid.rowHeight + 1); rowChanged(index); } buttonWrapper.append( @@ -1457,7 +1789,19 @@ export class Grid { const header = createElement('tr'); thead.appendChild(header); const sizer = this._var.refs.sizer; - let left = 0; + let left = this.expandable ? ExpandableWidth : 0; + 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'); @@ -1600,42 +1944,47 @@ export class Grid { width += col.width + 1; } } - if (width > 0) { - table.style.width = `${width}px`; + if (this.expandable) { + width += ExpandableWidth; } + table.style.width = `${width}px`; // body content - body.addEventListener('mousedown', e => { - let [parent, target] = this._getRowTarget(e.target); - const rowIndex = indexOfParent(parent); - let colIndex = indexOfParent(target); - if (colIndex >= this.columns.length) { - colIndex = -1; - } - this._onRowClicked(e, rowIndex, colIndex); - }); body.addEventListener('dblclick', e => this._onRowDblClicked(e)); - // this._adjustRows(); + // this._adjustRows(body); this._var.refs.body = body; // this.refresh(); return body; } - _adjustRows() { + _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 content = this._var.refs.body; - const exists = content.children.length; + const exists = content.querySelectorAll('&>.ui-grid-row').length; count -= exists; if (count > 0) { for (let i = 0; i < count; ++i) { const row = createElement('tr', 'ui-grid-row'); - let left = 0; + 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'); + const cell = createElement('td', 'ui-grid-cell'); if (col.visible !== false) { let style = this._get(col.key, 'style') ?? {}; if (col.isfixed) { @@ -1677,9 +2026,11 @@ export class Grid { content.appendChild(row); } } else if (count < 0) { - for (let i = -1; i >= count; i -= 1) { - // content.removeChild(content.children[exists + i]); - content.children[exists + i].remove(); + let last = content.querySelectorAll('&>.ui-grid-row')[exists + count]; + while (last != null) { + const next = last.nextElementSibling; + last.remove(); + last = next; } } } @@ -1698,6 +2049,7 @@ export class Grid { this._var.oldSelectedIndexes = selectedIndexes.slice(); } } + const offset = this.expandable ? 1 : 0; [...rows].forEach((row, i) => { const vals = this._var.currentSource[startIndex + i]; if (vals == null) { @@ -1714,11 +2066,59 @@ export class Grid { 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]; + const cell = row.children[j + offset]; if (cell == null) { return; } @@ -1833,12 +2233,13 @@ export class Grid { style.width = w; style['max-width'] = w; style['min-width'] = w; - const headerCells = this._var.refs.header.children; + const headerCells = this._headerCells; let element = headerCells[index]; element.style.width = w; element.style.maxWidth = w; element.style.minWidth = w; - let left = 0; + // 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; @@ -1851,15 +2252,17 @@ export class Grid { } } } - for (let row of this.tableRows) { - element = row.children[index]; + 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 + 1; i < this.columns.length; ++i) { + 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; @@ -1873,7 +2276,7 @@ export class Grid { } _changingColumnOrder(index, offset, mouse, draggerCellLeft) { - const children = this._var.refs.header.children; + 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); @@ -1945,9 +2348,10 @@ export class Grid { return; } const header = this._var.refs.header; - const children = header.children; - const rows = this.tableRows; + 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]; @@ -1959,7 +2363,7 @@ export class Grid { columns.splice(targetIndex, 0, current); header.insertBefore(children[index], children[targetIndex].nextElementSibling); for (let row of rows) { - row.insertBefore(row.children[index], row.children[targetIndex].nextElementSibling); + row.insertBefore(row.children[index + offset], row.children[targetIndex + offset].nextElementSibling); } } else { targetIndex = orderIndex; @@ -1972,7 +2376,7 @@ export class Grid { columns.splice(targetIndex, 0, current); header.insertBefore(children[index], children[targetIndex]); for (let row of rows) { - row.insertBefore(row.children[index], row.children[targetIndex]); + row.insertBefore(row.children[index + offset], row.children[targetIndex + offset]); } } if (this.sortArray == null || this.sortArray.length === 0) { @@ -2066,6 +2470,13 @@ export class Grid { return [parent, target]; } + _getParentElement(element) { + while (element != null && element.className !== 'ui-grid') { + element = element.parentElement; + } + return element; + } + _notHeader(tagName) { return /^(input|label|layer|svg|use)$/i.test(tagName); } @@ -2243,7 +2654,11 @@ export class Grid { if (typeof col.onFilterOk === 'function') { col.onFilterOk.call(this, col, array); } else { - col.filterValues = array.map(a => a.DisplayValue); + if (GridColumnTypeEnum.isCheckbox(col.type)) { + col.filterValues = array.map(a => a.Value); + } else { + col.filterValues = array.map(a => a.DisplayValue); + } } this._var.colAttrs.__filtered = true; this._refreshSource(); @@ -2287,8 +2702,9 @@ export class Grid { 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'; for (let item of array) { - const v = Object.prototype.hasOwnProperty.call(item, 'DisplayValue') ? item.DisplayValue : item; + const v = Object.prototype.hasOwnProperty.call(item, propKey) ? item[propKey] : item; item.__checked = !Array.isArray(col.filterValues) || col.filterValues.includes(v); } if (array.length > 12) { @@ -2350,7 +2766,7 @@ export class Grid { if (e.currentTarget.classList.contains('sticky')) { return; } - const index = indexOfParent(e.currentTarget); + const index = indexOfParent(e.currentTarget) - (this.expandable ? 1 : 0); const cx = getClientX(e); const clearEvents = attr => { for (let event of ['mousemove', 'mouseup']) { @@ -2411,7 +2827,7 @@ export class Grid { _onResizeStart(e, col) { const cx = getClientX(e); const width = col.width; - const index = indexOfParent(e.currentTarget.parentElement); + const index = indexOfParent(e.currentTarget.parentElement) - (this.expandable ? 1 : 0); const window = this.window ?? global; const clearEvents = attr => { for (let event of ['mousemove', 'mouseup']) { @@ -2462,9 +2878,10 @@ export class Grid { _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].children[0]; + for (let row of this._tableRows) { + const element = row.children[index + offset].children[0]; const w = element.scrollWidth; if (w > width) { width = w; @@ -2475,9 +2892,9 @@ export class Grid { } if (width > 0 && width !== col.width) { width += 12; - this._changeColumnWidth(index, width); + this._changeColumnWidth(index - offset, width); if (typeof this.onColumnChanged === 'function') { - this.onColumnChanged(ColumnChangedType.Resize, index, width); + this.onColumnChanged(ColumnChangedType.Resize, index - offset, width); } } } @@ -2524,7 +2941,8 @@ export class Grid { this._scrollToTop(top); } - _onBodyMouseMove(e, holder) { + _onGridMouseMove(e, holder) { + e.stopPropagation(); if (e.target.classList.contains('ui-grid-hover-holder')) { return; } @@ -2537,6 +2955,10 @@ export class Grid { } 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')) { @@ -2621,7 +3043,7 @@ export class Grid { if (this.readonly !== true) { this.refresh(); } else { - this.tableRows.forEach((row, i) => { + this._tableRows.forEach((row, i) => { if (selectedIndexes.includes(startIndex + i)) { row.classList.add('selected'); } else if (row.classList.contains('selected')) { @@ -2705,4 +3127,13 @@ export class Grid { } } } + + _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(); + } } \ No newline at end of file