diff --git a/.gitignore b/.gitignore index f44974deab..d9a88e581f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store node_modules +.pnpm-store/ dist/ dist2/ dist2.7/ diff --git a/examples/sites/demos/pc/app/grid/span/row-span-with-expand-composition-api.vue b/examples/sites/demos/pc/app/grid/span/row-span-with-expand-composition-api.vue new file mode 100644 index 0000000000..dfc5c95c64 --- /dev/null +++ b/examples/sites/demos/pc/app/grid/span/row-span-with-expand-composition-api.vue @@ -0,0 +1,71 @@ + + + diff --git a/examples/sites/demos/pc/app/grid/span/row-span-with-expand.spec.js b/examples/sites/demos/pc/app/grid/span/row-span-with-expand.spec.js new file mode 100644 index 0000000000..271f47e70c --- /dev/null +++ b/examples/sites/demos/pc/app/grid/span/row-span-with-expand.spec.js @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test' + +test('行合并与展开行:展开为全宽且不重叠', async ({ page }) => { + page.on('pageerror', (exception) => expect(exception).toBeNull()) + await page.goto('grid-span#span-row-span-with-expand') + + const areaCell = page.getByRole('cell', { name: '华南区' }).first() + await expect(areaCell).toHaveAttribute('rowspan', '3') + + await page.getByRole('row', { name: /中山/ }).locator('.tiny-grid__expanded').click() + const expandRow = page.locator('.tiny-grid-body__expanded-row') + await expect(expandRow).toBeVisible() + await expect(page.locator('.tiny-grid-body__expanded-cell')).toContainText('中山') + await expect(expandRow.locator('td')).toHaveAttribute('colspan', '6') + await expect(areaCell).toHaveAttribute('rowspan', '2') + + const shaoguanRow = page.getByRole('row', { name: /韶关/ }) + await expect(shaoguanRow.getByRole('cell', { name: '华南区' })).toBeVisible() + + const expandBox = await expandRow.boundingBox() + const areaBox = await areaCell.boundingBox() + expect(expandBox.y).toBeGreaterThanOrEqual(areaBox.y + areaBox.height - 2) +}) diff --git a/examples/sites/demos/pc/app/grid/span/row-span-with-expand.vue b/examples/sites/demos/pc/app/grid/span/row-span-with-expand.vue new file mode 100644 index 0000000000..da75f9e249 --- /dev/null +++ b/examples/sites/demos/pc/app/grid/span/row-span-with-expand.vue @@ -0,0 +1,80 @@ + + + diff --git a/examples/sites/demos/pc/app/grid/webdoc/grid-span.js b/examples/sites/demos/pc/app/grid/webdoc/grid-span.js index 8d6c201eb2..e112305ff3 100644 --- a/examples/sites/demos/pc/app/grid/webdoc/grid-span.js +++ b/examples/sites/demos/pc/app/grid/webdoc/grid-span.js @@ -22,6 +22,20 @@ export default { }, codeFiles: ['span/row-span.vue'] }, + { + demoId: 'span-row-span-with-expand', + name: { + 'zh-CN': '行合并与展开行', + 'en-US': 'Row Span with Expand' + }, + desc: { + 'zh-CN': + '

同时配置 row-span 与列 type="expand"。展开行占满整行宽度;合并在展开处断开,避免与展开内容重叠。

\n', + 'en-US': + '

Use row-span with column type="expand". The expand row is full width; merges split at the expand so content does not overlap.

\n' + }, + codeFiles: ['span/row-span-with-expand.vue'] + }, { demoId: 'span-column-span', name: { 'zh-CN': '列合并', 'en-US': 'Column Merge' }, diff --git a/packages/vue/src/grid/__tests__/split-rowspan-at-expand.test.ts b/packages/vue/src/grid/__tests__/split-rowspan-at-expand.test.ts new file mode 100644 index 0000000000..d4b49f114c --- /dev/null +++ b/packages/vue/src/grid/__tests__/split-rowspan-at-expand.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'vitest' +import { splitRowspanAtExpand } from '../src/composable/useCellSpan' + +const col = (property) => ({ property, id: property }) + +const cell = (rowspan, column, row, extra = {}) => ({ + attrs: { rowspan, colspan: rowspan > 0 ? 1 : 0, visible: rowspan > 0, _dataRowspan: rowspan, ...extra }, + params: { column, row } +}) + +describe('splitRowspanAtExpand', () => { + test('no-op without expandeds', () => { + const area = col('area') + const data = [{ area: 'A' }, { area: 'A' }, { area: 'A' }] + const rows = [[cell(3, area, data[0])], [cell(0, area, data[1])], [cell(0, area, data[2])]] + splitRowspanAtExpand(rows, { expandeds: [], rowSpan: [{ field: 'area' }] }) + expect(rows.map((r) => r[0].attrs.rowspan)).toEqual([3, 0, 0]) + }) + + test('expand without merges is identity', () => { + const city = col('city') + const r0 = { city: '深圳' } + const r1 = { city: '中山' } + const rows = [[cell(1, city, r0)], [cell(1, city, r1)]] + splitRowspanAtExpand(rows, { expandeds: [r0], rowSpan: [] }) + expect(rows.map((r) => r[0].attrs.rowspan)).toEqual([1, 1]) + expect(rows[0][0].attrs.colspan).toBe(1) + }) + + test('splits merge when middle row expands (#4200)', () => { + const area = col('area') + const r0 = { area: '华南区' } + const r1 = { area: '华南区' } + const r2 = { area: '华南区' } + const rows = [[cell(3, area, r0)], [cell(0, area, r1)], [cell(0, area, r2)]] + + splitRowspanAtExpand(rows, { + expandeds: [r1], + rowSpan: [{ field: 'area' }] + }) + + expect(rows[0][0].attrs).toMatchObject({ rowspan: 2, visible: true }) + expect(rows[1][0].attrs).toMatchObject({ rowspan: 0, visible: false }) + expect(rows[2][0].attrs).toMatchObject({ rowspan: 1, visible: true }) + }) + + test('expand on first row restarts merge below', () => { + const area = col('area') + const r0 = { area: 'A' } + const r1 = { area: 'A' } + const r2 = { area: 'A' } + const rows = [[cell(3, area, r0)], [cell(0, area, r1)], [cell(0, area, r2)]] + + splitRowspanAtExpand(rows, { + expandeds: [r0], + rowSpan: [{ field: 'area' }] + }) + + expect(rows[0][0].attrs.rowspan).toBe(1) + expect(rows[1][0].attrs).toMatchObject({ rowspan: 2, visible: true }) + expect(rows[2][0].attrs).toMatchObject({ rowspan: 0, visible: false }) + }) + + test('expand on last row of group keeps full merge above', () => { + const area = col('area') + const r0 = { area: 'A' } + const r1 = { area: 'A' } + const r2 = { area: 'A' } + const rows = [[cell(3, area, r0)], [cell(0, area, r1)], [cell(0, area, r2)]] + + splitRowspanAtExpand(rows, { + expandeds: [r2], + rowSpan: [{ field: 'area' }] + }) + + expect(rows.map((r) => r[0].attrs.rowspan)).toEqual([3, 0, 0]) + }) + + test('spanMethod covered cells stay hidden after expand', () => { + const a = col('a') + const b = col('b') + const r0 = { a: 1, b: 2 } + const r1 = { a: 1, b: 2 } + const covered = (column, row) => cell(0, column, row, { colspan: 0, visible: false, _dataRowspan: 0 }) + const rows = [ + [cell(2, a, r0, { colspan: 2, _dataRowspan: 2 }), covered(b, r0)], + [covered(a, r1), covered(b, r1)] + ] + + splitRowspanAtExpand(rows, { + expandeds: [r0], + spanMethod: () => ({}) + }) + + expect(rows[0][0].attrs).toMatchObject({ rowspan: 1, colspan: 2, visible: true }) + expect(rows[0][1].attrs).toMatchObject({ rowspan: 0, colspan: 0, visible: false }) + expect(rows[1][0].attrs).toMatchObject({ rowspan: 0, colspan: 0, visible: false }) + expect(rows[1][1].attrs).toMatchObject({ rowspan: 0, colspan: 0, visible: false }) + }) +}) diff --git a/packages/vue/src/grid/src/composable/useCellSpan.ts b/packages/vue/src/grid/src/composable/useCellSpan.ts index 68eb65cfd6..fa13290fad 100644 --- a/packages/vue/src/grid/src/composable/useCellSpan.ts +++ b/packages/vue/src/grid/src/composable/useCellSpan.ts @@ -13,7 +13,8 @@ export const useCellSpan = (bodyVm, bodyProps) => { () => $table.visibleColumn, () => bodyProps.tableData, () => $table.isColumnWidthAssigned, - () => $table.columnStore.resizeList + () => $table.columnStore.resizeList, + () => $table.expandeds.map((r) => getRowid($table, r)).join() ], ([visibleColumn, tableData, isColumnWidthAssigned]) => { if (!Array.isArray(tableData) || !isColumnWidthAssigned) { @@ -76,6 +77,8 @@ export const useCellSpan = (bodyVm, bodyProps) => { stateNormalCell(normalState, normalTable, params, $table) } + splitRowspanAtExpand(normalTable, $table) + if (leftList.length > 0) { adjustColspan(normalTable, leftList.length - 1, true) } @@ -184,6 +187,7 @@ const doSpan = ({ attrs, params, rowSpan, spanMethod }) => { attrs.rowspan = rowspan attrs.colspan = colspan + attrs._dataRowspan = rowspan attrs.visible = rowspan > 0 && colspan > 0 } @@ -216,6 +220,64 @@ const rowSpanMethod = (rowSpan, { row, $rowIndex, column, data }) => { } } +export const splitRowspanAtExpand = (normalTable, $table) => { + const expandeds = $table.expandeds + if (!expandeds?.length || !normalTable.length) return + + const expandedSet = new Set(expandeds) + const colCount = normalTable[0].length + const hasSpanMethod = typeof $table.spanMethod === 'function' + const rowAt = (r) => normalTable[r][0].params.row + + for (let c = 0; c < colCount; c++) { + let coverUntil = -1 + for (let r = 0; r < normalTable.length; r++) { + const { attrs, params } = normalTable[r][c] + if (r <= coverUntil) { + attrs.rowspan = 0 + attrs.colspan = 0 + attrs.visible = false + continue + } + + let intended = attrs._dataRowspan + if (!(intended > 0)) { + if (hasSpanMethod) continue + intended = remainingEqualSpan(normalTable, r, params.column, $table) + if (!(intended > 0)) continue + } + + let span = 1 + while (span < intended && r + span < normalTable.length) { + if (expandedSet.has(rowAt(r + span - 1))) break + span++ + } + + attrs.rowspan = span + if (!(attrs.colspan > 0)) attrs.colspan = 1 + attrs.visible = true + coverUntil = r + span - 1 + } + } +} + +const remainingEqualSpan = (normalTable, start, column, $table) => { + const { rowSpan, spanMethod } = $table + if (spanMethod || !rowSpan?.length || !column?.property) return 1 + + const fields = rowSpan.map((item) => item.field) + if (!fields.includes(column.property)) return 1 + + const cellVal = normalTable[start][0].params.row?.[column.property] + if (!cellVal) return 1 + + let count = 1 + while (start + count < normalTable.length && normalTable[start + count][0].params.row[column.property] === cellVal) { + count++ + } + return count +} + const adjustColspan = (table, pos, isLeft) => { if (pos < 0) { return