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 @@
+
+
+
+
+
+
+ -
+ 公司名称:
+ {{ row.name }}
+
+ -
+ 区域:
+ {{ row.area }}
+
+ -
+ 城市:
+ {{ row.city }}
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ -
+ 公司名称:
+ {{ row.name }}
+
+ -
+ 区域:
+ {{ row.area }}
+
+ -
+ 城市:
+ {{ row.city }}
+
+
+
+
+
+
+
+
+
+
+
+
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