u
This commit is contained in:
1 parent
f3936cec61
commit
15e0ae5b67
115 files changed
+11399
-2741
No files matched your search
@@ -337,6 +337,40 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
|
||||
|
||||
expect(await rowHeights(page, '[data-testid="table-wrap"]')).toEqual(before)
|
||||
})
|
||||
|
||||
/**
|
||||
* select 的编辑格底色。
|
||||
*
|
||||
* 回归的 bug:编辑格的白底判据曾经是 `td:focus-within`,而 **G3Select 展开时会把 DOM
|
||||
* 焦点移进 Teleport 到 body 的下拉浮层** —— 那一刻 td 的 `:focus-within` 立刻失效,
|
||||
* 底色退回库的选区色(`--cs-bgc`,浅蓝)。表现是「select 编辑格时白时不白」,
|
||||
* 而 input 没有浮层、永远正常。
|
||||
*
|
||||
* 所以这里必须在**浮层已夺焦**的状态下断言,用别的方式(例如只看点击后一瞬间)
|
||||
* 是测不出来的。判据改为编辑器上的 `is-cell-editing` 类(见 editable.ts)。
|
||||
*/
|
||||
test('select 展开(焦点被浮层拿走)后,编辑格仍是基色而不是选区色', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
const cell = await cellByTitle(page, '[data-testid="table-wrap"]', '状态')
|
||||
await cell.click()
|
||||
|
||||
// 前置条件:浮层确实拿到了焦点,且焦点已不在表格里(这正是修前的失效条件)
|
||||
await expect(page.locator('.g3-select__dropdown')).toBeVisible()
|
||||
expect(await page.evaluate(() => !document.activeElement?.closest('.stk-table'))).toBe(true)
|
||||
|
||||
const measured = await cell.evaluate((el) => ({
|
||||
bg: getComputedStyle(el).backgroundColor,
|
||||
marked: !!el.querySelector('.is-cell-editing'),
|
||||
}))
|
||||
|
||||
// 机制在:编辑器带着「当前编辑格」标记(td 侧靠 :has(> …) 命中)
|
||||
expect(measured.marked).toBe(true)
|
||||
// 底色是基色(亮色主题下即 #fff),不是库的选区色 #d3eafd
|
||||
expect(measured.bg).toBe('rgb(255, 255, 255)')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -349,6 +383,71 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
|
||||
* - table-wrap 绑了 v-model:columns(列宽拖拽的前提,缺了拖不动)
|
||||
*/
|
||||
test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
|
||||
test('不可改的格子灰底常驻:整列只读 / 按行禁用,都不需要先点开某行', async ({ page }) => {
|
||||
/**
|
||||
* 判据是「**这一格现在改不了**」,不再是「编辑行里没有编辑器的格子」——
|
||||
* 后者只在被点过的那一行可见,用户仍要一行行点过去才发现某格点不动。
|
||||
*
|
||||
* 两种来源分别锁:整列只读(列上没有 editor,走 td 类名)与按行禁用
|
||||
* (`disabled` 是函数,走内容节点标记);顺带反向断言可编辑列**不**灰。
|
||||
*/
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
const rows = wrap.locator('.stk-tbody-main tr')
|
||||
const bgOf = (selector: string, rowIndex: number) =>
|
||||
rows.nth(rowIndex).locator(selector).evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
|
||||
// 「名称」列没有 editor(整列只读)→ 进页面就是灰的
|
||||
expect(await bgOf('td[data-col-key="name"]', 0)).not.toBe('rgba(0, 0, 0, 0)')
|
||||
// 「金额」列有 editor → 不灰
|
||||
expect(await bgOf('td[data-col-key="amount"]', 0)).toBe('rgba(0, 0, 0, 0)')
|
||||
|
||||
// 按行禁用:第 2 行 enabled=0 → 状态列灰;第 1 行不灰
|
||||
expect(await bgOf('td[data-col-key="status"]', 1)).not.toBe('rgba(0, 0, 0, 0)')
|
||||
expect(await bgOf('td[data-col-key="status"]', 0)).toBe('rgba(0, 0, 0, 0)')
|
||||
})
|
||||
|
||||
/**
|
||||
* 灰底的门控:**这张表里得先有人能编辑**,灰底才是信息。
|
||||
*
|
||||
* 灰底的语义是「这一格现在改不了」,它靠「旁边有可改的格子」传达意义。
|
||||
* 一张纯展示表(列上都没有 `editor`)里每格都改不了,若照旧逐格铺灰,
|
||||
* 整张表看上去就是「禁用状态」—— 文档 7 张 demo 里有 6 张是这种纯展示表,
|
||||
* 用户看到的就是「demo 一片灰、像是被禁用了」。
|
||||
*
|
||||
* 两个反例都锁上:
|
||||
* · `table-plain-wrap`:列上根本没有 `editor`;
|
||||
* · `table-noedit-wrap`:列上有 `editor`,但 `editable: false` 把编辑整个关掉。
|
||||
* 正向(可编辑表里的只读列**仍然**常驻灰底)由上面那条用例守着,两条一起构成门控。
|
||||
*/
|
||||
test('纯展示表 / editable: false:不铺「不可改」灰底', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const greyCount = async (wrapTestId: string) =>
|
||||
page
|
||||
.locator(`[data-testid="${wrapTestId}"]`)
|
||||
.locator('.stk-tbody-main td')
|
||||
.evaluateAll(
|
||||
(tds) =>
|
||||
tds.filter(
|
||||
(td) =>
|
||||
// 整列只读:类名落在 td 上
|
||||
td.classList.contains('g3-table__cell-readonly') ||
|
||||
// 按行禁用:标记落在内容节点上
|
||||
!!td.querySelector('.g3-table__cell-readonly'),
|
||||
).length,
|
||||
)
|
||||
|
||||
// 纯展示表:一个灰格都不该有
|
||||
expect(await greyCount('table-plain-wrap')).toBe(0)
|
||||
// editable: false:同理(列上声明了 editor 也不算「有人能编辑」)
|
||||
expect(await greyCount('table-noedit-wrap')).toBe(0)
|
||||
|
||||
// 对照:可编辑的 table-wrap 里,没有 editor 的「名称」列仍然有灰底(门控不能把这条改坏)
|
||||
expect(await greyCount('table-wrap')).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('校验态全表可见:不点任何行就能看到 error 与 warning', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
@@ -381,6 +480,59 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
|
||||
await expect(row.locator('td').nth(remarkIdx).locator('.is-editor-warning')).toHaveCount(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* 编辑态让位:正在编辑的校验格**不铺校验底色**,底色回到基色。
|
||||
*
|
||||
* 原因(实际录入反馈):铺着底色时输入区被染色、对比度下降,录入时「看不清」。
|
||||
* 编辑时的校验信号改由**语义色焦点环 + 红字**承载;离开该格后底色回来。
|
||||
*
|
||||
* 判据是编辑器上的 `is-cell-editing` 标记,不是 `:focus-within`:
|
||||
* select 展开时焦点被移进 Teleport 到 body 的浮层,`:focus-within` 会时有时无。
|
||||
*
|
||||
* 顺带锁住「校验格与普通格内边距一致」:右上角的 `!` 角标已**整体去掉**,
|
||||
* 为它让位的 `padding-right: 14px` 也一并删除 —— 录入区不该被缩窄。
|
||||
*/
|
||||
test('编辑校验格:底色回到基色;换格后恢复;不再为角标留右边距', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
// 宿主第 2 行「金额」= 0 → validate 不通过,是这张表里的 error 格
|
||||
const errCell = wrap.locator('.stk-tbody-main tr').nth(1).locator('td[data-col-key="amount"]')
|
||||
const read = () =>
|
||||
errCell.evaluate((el) => ({
|
||||
bg: getComputedStyle(el).backgroundColor,
|
||||
badge: getComputedStyle(el, '::after').content,
|
||||
shadow: getComputedStyle(el).boxShadow,
|
||||
}))
|
||||
|
||||
// ① 未编辑:铺校验底色;角标已去掉(::after 无内容)
|
||||
const idle = await read()
|
||||
expect(idle.bg).not.toBe('rgb(255, 255, 255)')
|
||||
expect(idle.badge).toBe('none')
|
||||
|
||||
// 内边距与同列的普通格一致(没有为角标让位的 14px)
|
||||
const displayPad = (rowIndex: number) =>
|
||||
wrap
|
||||
.locator('.stk-tbody-main tr')
|
||||
.nth(rowIndex)
|
||||
.locator('td[data-col-key="amount"] .g3-table__cell-display')
|
||||
.evaluate((el) => getComputedStyle(el).paddingRight)
|
||||
expect(await displayPad(1)).toBe(await displayPad(0))
|
||||
|
||||
// ② 进入编辑:底色 = 基色(亮色主题即 #fff),只剩语义色焦点环
|
||||
await errCell.click()
|
||||
await expect(wrap.locator('.is-cell-editing')).toHaveCount(1)
|
||||
const editing = await read()
|
||||
expect(editing.bg).toBe('rgb(255, 255, 255)')
|
||||
expect(editing.badge).toBe('none')
|
||||
expect(editing.shadow).toContain('2px')
|
||||
|
||||
// ③ 换到别的格 → 它不再是编辑格,底色回来
|
||||
await wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="amount"]').click()
|
||||
await expect.poll(async () => (await read()).bg).not.toBe('rgb(255, 255, 255)')
|
||||
})
|
||||
|
||||
test('列宽拖拽:绑定 v-model:columns 后可拖动,宽度真实变化', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-wrap"]')
|
||||
@@ -533,3 +685,513 @@ test.describe('G3Table RTL 镜像', () => {
|
||||
expect(tableBox!.width).toBeGreaterThan(wrapBox!.width * 0.9)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 固定列 + 校验态的几何契约。
|
||||
*
|
||||
* 历史 bug(值得留在这里,因为它是这类问题的模板):校验态样式曾给 td 写
|
||||
* `position: relative`(为了让绝对定位的角标有个基准),而固定列的 td 上
|
||||
* `position` 是库在用的 —— 被遮挡时是 `sticky`,且吸附位移以**内联** `left` / `right`
|
||||
* 写在 td 上。改成 relative 之后那条内联 `left` 就变成普通相对位移,整个格子被推开
|
||||
* 「前面所有左固定列宽度之和」:表现是「错误底色跑到了隔壁两列中间」,
|
||||
* 看着完全不像定位问题。
|
||||
*
|
||||
* 角标现在整体去掉了(见 docs 的 errorStyle),那两条 `position` 规则也随之删除 ——
|
||||
* 这个坑从机制上消失。本组用例继续守着「固定列 + 校验底色」的几何:
|
||||
* 校验格必须与它所属列对齐、滚完仍吸附在原位。
|
||||
*
|
||||
* 只能在这里量:happy-dom 没有布局引擎,`:has()` 命中与 sticky 都不成立。
|
||||
* 宿主数据在 playground/main.ts(`table-fixed-wrap`):左固定两列 + 一行空名称。
|
||||
*/
|
||||
test.describe('G3Table 固定列 + 校验态', () => {
|
||||
const WRAP = '[data-testid="table-fixed-wrap"]'
|
||||
|
||||
/** 按列标题取表头单元格的几何(固定列的表头与数据格走同一套 sticky) */
|
||||
async function thBox(page: import('@playwright/test').Page, title: string) {
|
||||
const th = page.locator(WRAP).locator('thead th', { hasText: title })
|
||||
await expect(th).toBeVisible()
|
||||
const box = await th.boundingBox()
|
||||
if (!box) throw new Error(`表头「${title}」没有几何信息`)
|
||||
return box
|
||||
}
|
||||
|
||||
/** 按列标题取「第一行」的单元格:列序由调用方决定,不写死 nth-child */
|
||||
async function cellByTitleIn(
|
||||
page: import('@playwright/test').Page,
|
||||
wrapSelector: string,
|
||||
title: string,
|
||||
) {
|
||||
const wrap = page.locator(wrapSelector)
|
||||
const index = await wrap
|
||||
.locator('thead th')
|
||||
.evaluateAll((ths, t) => ths.findIndex((th) => (th.textContent || '').trim() === t), title)
|
||||
if (index < 0) throw new Error(`找不到列标题「${title}」`)
|
||||
return wrap.locator('.stk-tbody-main tr').first().locator('td').nth(index)
|
||||
}
|
||||
|
||||
/** 滚动到最右,返回实际 scrollLeft(为 0 说明这张表根本没横向溢出,用例失去意义) */
|
||||
async function scrollToEnd(page: import('@playwright/test').Page) {
|
||||
return page.locator(`${WRAP} .stk-table`).evaluate((el) => {
|
||||
el.scrollLeft = el.scrollWidth
|
||||
return el.scrollLeft
|
||||
})
|
||||
}
|
||||
|
||||
test('校验未通过的格子留在本列内,不被固定列偏移推开', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator(WRAP)
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
await page.locator(`${WRAP} .stk-table`).evaluate((el) => {
|
||||
el.scrollLeft = 0
|
||||
})
|
||||
|
||||
const nameTh = await thBox(page, '名称(校验列)')
|
||||
|
||||
// 第 2 行名称为空 → 固定列上必然有一个 error 格(同页其它表格的校验格不在这个 wrap 内)
|
||||
const errorCell = wrap.locator('td:has(> .is-editor-error)')
|
||||
await expect(errorCell).toHaveCount(1)
|
||||
const cell = await errorCell.boundingBox()
|
||||
expect(cell).not.toBeNull()
|
||||
|
||||
// 起点不许被推右:错误格与它所属列的表头左边缘对齐(1px 容差给亚像素)
|
||||
expect(Math.abs(cell!.x - nameTh.x)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(cell!.width - nameTh.width)).toBeLessThanOrEqual(1)
|
||||
|
||||
// 而且必须真的在「前一列」的右边 —— 挡住「整格被推走但恰好没超出容差」的漏网
|
||||
const codeTh = await thBox(page, '编号(左固定)')
|
||||
expect(cell!.x).toBeGreaterThanOrEqual(codeTh.x + codeTh.width - 1)
|
||||
})
|
||||
|
||||
test('受控 activeRowKey:点可编辑格进入编辑,当前行照样高亮', async ({ page }) => {
|
||||
/**
|
||||
* 回归的 bug:受控调用方(fms 那套 `:active-row-key` + `@row-click`)
|
||||
* 点**可编辑格**进编辑时整行不高亮,点只读格却正常。
|
||||
*
|
||||
* 根因在浏览器:点可编辑格会挂载编辑器,把 mousedown 命中的 `.cell-display`
|
||||
* span 换掉,Chromium 因此**不派发 click**,库的 `row-click` 不触发,
|
||||
* 调用方那个唯一写入者永远收不到消息。
|
||||
* 组件侧改为在「激活编辑」这条路径上无条件同步当前行(见 G3Table 注释)。
|
||||
*/
|
||||
await page.goto('/')
|
||||
const wrap = page.locator(WRAP)
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
const rows = wrap.locator('.stk-tbody-main tr')
|
||||
const firstRow = rows.first()
|
||||
await expect(firstRow).not.toHaveClass(/active/)
|
||||
|
||||
// 「名称(校验列)」是这张表里的可编辑列(带 editor),点它进入编辑
|
||||
const editableCell = await cellByTitleIn(page, WRAP, '名称(校验列)')
|
||||
await editableCell.click()
|
||||
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
|
||||
|
||||
// 当前行必须高亮 —— 这正是调用方靠 row-click 拿不到的那次点击
|
||||
await expect(firstRow).toHaveClass(/active/)
|
||||
|
||||
// 只读格那条路径也不能坏(第二行的「编号」列没有 editor)
|
||||
await rows.nth(1).locator('td[data-col-key="code"]').click()
|
||||
await expect(rows.nth(1)).toHaveClass(/active/)
|
||||
})
|
||||
|
||||
test('横向滚动后:左固定列与其中的错误格一起吸附在原位', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator(WRAP)
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
const beforeCode = await thBox(page, '编号(左固定)')
|
||||
const beforeError = await wrap.locator('td:has(> .is-editor-error)').boundingBox()
|
||||
expect(beforeError).not.toBeNull()
|
||||
|
||||
// 前提校验:这张表确实溢出(否则固定列无从谈起,断言会变成「什么都没测」)
|
||||
expect(await scrollToEnd(page)).toBeGreaterThan(100)
|
||||
await page.waitForTimeout(150)
|
||||
|
||||
const afterCode = await thBox(page, '编号(左固定)')
|
||||
const afterError = await wrap.locator('td:has(> .is-editor-error)').boundingBox()
|
||||
expect(afterError).not.toBeNull()
|
||||
|
||||
// 左固定列滚完仍在视口的同一横坐标上(吸附生效)
|
||||
expect(Math.abs(afterCode.x - beforeCode.x)).toBeLessThanOrEqual(1)
|
||||
// 带校验态的那一格同理:校验态样式不许把吸附定位打断
|
||||
expect(Math.abs(afterError!.x - beforeError!.x)).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* 冻结列的选中态与行状态叠色的关系。
|
||||
*
|
||||
* 回归的 bug:新加的「冻结列行状态叠色」用的是 `box-shadow`,而库的选区描边
|
||||
* (`td[data-cs-s]` 的 4 个方向 inset)**也在 box-shadow 上** —— 单值属性,
|
||||
* 只写叠色那一条就会把选中框整体替换掉。表现是「冻结列上的选中格没有蓝色选中边」,
|
||||
* 非冻结列没有这条规则所以只有冻结列会缺,很容易被当成「选中态没生效」。
|
||||
*
|
||||
* 另一半:编辑格**不参与**行状态叠色(普通列的编辑格是纯基色 + 焦点环),
|
||||
* 否则同一行里冻结列的编辑格会带行色、普通列的却是纯色。
|
||||
*/
|
||||
test('冻结列:选中格保留选中描边,编辑格与普通列一样只有焦点环', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator(WRAP)
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
const firstRow = wrap.locator('.stk-tbody-main tr').first()
|
||||
const nameCell = await cellByTitleIn(page, WRAP, '名称(校验列)')
|
||||
await nameCell.click()
|
||||
|
||||
/**
|
||||
* 编辑格:应当只有「一圈 2px 内描边」(焦点环),**不带**行状态的大范围叠色。
|
||||
* 叠色的计算值是 `0px 0px 0px <视口宽>px inset`,形状完全不同 —— 用形状区分,
|
||||
* 不去比颜色(颜色随主题变)。
|
||||
*/
|
||||
const editingShadow = await nameCell.evaluate((el) => getComputedStyle(el).boxShadow)
|
||||
expect(editingShadow).toMatch(/0px 0px 0px 2px inset/)
|
||||
|
||||
// 再 Shift+点前面那个只读的左固定格:选区扩成两格,它就是「选中但不在编辑」的格子
|
||||
const codeCell = firstRow.locator('td[data-col-key="code"]')
|
||||
await codeCell.click({ modifiers: ['Shift'] })
|
||||
await page.waitForTimeout(150)
|
||||
|
||||
const measured = await codeCell.evaluate((el) => {
|
||||
// 把 --cs-bc(选区描边色)解析成 rgb:getPropertyValue 给的是原样字符串,比不了
|
||||
const probe = document.createElement('span')
|
||||
probe.style.color = 'var(--cs-bc)'
|
||||
el.appendChild(probe)
|
||||
const borderColor = getComputedStyle(probe).color
|
||||
probe.remove()
|
||||
return {
|
||||
isFixed: el.classList.contains('fixed-cell'),
|
||||
isSelected: el.hasAttribute('data-cs-s'),
|
||||
shadow: getComputedStyle(el).boxShadow,
|
||||
borderColor,
|
||||
}
|
||||
})
|
||||
|
||||
expect(measured.isFixed).toBe(true)
|
||||
expect(measured.isSelected).toBe(true)
|
||||
// 叠色规则里必须连同选区描边一起写,否则这一格上的选中框会被替换掉
|
||||
expect(measured.shadow).toContain(measured.borderColor)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tab / Shift+Tab 跳格。
|
||||
*
|
||||
* 只能在这里测:事件源可能是 Teleport 到 body 的 select 浮层(真实 DOM 概念),
|
||||
* happy-dom 既没有 Teleport 的真实 DOM,也没有焦点语义。
|
||||
*
|
||||
* 宿主是 playground 的 `table-wrap` 表(可编辑),列序为
|
||||
* 名称(只读) / 金额(number) / 状态(select,enabled=0 的行禁用) / 启用(checkbox) / 备注(text) / 操作(只读),
|
||||
* 数据 3 行(第 2 行 enabled=0)。这一组合同时覆盖三条规则:
|
||||
* 跳过只读列、跳过「按行禁用」的列、行尾回绕到下一行。
|
||||
*/
|
||||
test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
|
||||
const WRAP = '[data-testid="table-wrap"]'
|
||||
|
||||
/**
|
||||
* 焦点当前所在的那一格。
|
||||
*
|
||||
* 不能只读 `document.activeElement.closest('td')`:select 展开时焦点被移进 Teleport 出去的
|
||||
* 浮层里,浮层不属于任何 td。此时用 ARIA 关联(浮层 id ↔ 触发器 aria-controls)把焦点
|
||||
* 还原成「它逻辑上所在的格」——与组件内部判定用的是同一套口径。
|
||||
* 焦点完全不在表格里(例如跑到了页面其它元素上)时返回 null —— 这正是修前会出现的状态。
|
||||
*/
|
||||
async function focusedCell(page: import('@playwright/test').Page) {
|
||||
return page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
if (!active) return null
|
||||
let td = active.closest('td[data-col-key]') as HTMLElement | null
|
||||
if (!td) {
|
||||
const overlay = active.closest('.g3-select__dropdown') as HTMLElement | null
|
||||
const trigger = overlay?.id
|
||||
? document.querySelector(`[aria-controls="${overlay.id}"]`)
|
||||
: null
|
||||
td = trigger?.closest('td[data-col-key]') ?? null
|
||||
}
|
||||
if (!td) return null
|
||||
return {
|
||||
colKey: td.getAttribute('data-col-key'),
|
||||
// 库给 tr 写了绝对行索引(不受虚拟窗口影响)
|
||||
rowIndex: td.closest('tr')?.getAttribute('data-row-i') ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('焦点在 select 的浮层里按 Tab:落到下一个可编辑格,而不是页面其它元素', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator(WRAP)
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
// 进入第一行「金额」格编辑(number 编辑器,焦点在原生 input 上)
|
||||
await wrap.locator('td[data-col-key="amount"]').first().click()
|
||||
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
|
||||
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' })
|
||||
|
||||
// ① input → Tab:落到相邻的 select 列;G3Select 展开后把焦点移进 Teleport 到 body 的浮层
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(page.locator('.g3-select__dropdown')).toBeVisible()
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'status', rowIndex: '0' })
|
||||
|
||||
/**
|
||||
* ② 关键回归:焦点在浮层里(DOM 上已经不在 `.stk-table` 内)时按 Tab。
|
||||
*
|
||||
* 修前:浮层的 keydown 到不了底座的 Tab 导航(监听器挂在表格容器上),
|
||||
* 浏览器按文档顺序把焦点移出表格 —— 实测落到文档开头的第一个可聚焦元素,
|
||||
* 用户看到的就是「按 Tab 没有选中下一个」。
|
||||
*/
|
||||
await page.keyboard.press('Tab')
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'enabled', rowIndex: '0' })
|
||||
|
||||
// ③ 只读的「操作」列被跳过,行尾回绕到下一行的第一个可编辑格
|
||||
await page.keyboard.press('Tab')
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
|
||||
await page.keyboard.press('Tab')
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '1' })
|
||||
|
||||
// ④ 第二行 status 是「按行禁用」的(enabled=0):它连同只读列一起被跳过
|
||||
await page.keyboard.press('Tab')
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'enabled', rowIndex: '1' })
|
||||
})
|
||||
|
||||
test('Shift+Tab 反向跳格,并在两端循环', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator(WRAP)
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
// 从第二行「金额」往回:先跨过只读的「操作」列落到上一行「备注」
|
||||
await wrap.locator('td[data-col-key="amount"]').nth(1).click()
|
||||
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
|
||||
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '1' })
|
||||
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
|
||||
|
||||
// 全表第一个可编辑格:「金额」再往回 → 循环到全表最后一格(第三行「备注」)
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'enabled', rowIndex: '0' })
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'status', rowIndex: '0' })
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' })
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '2' })
|
||||
})
|
||||
|
||||
/**
|
||||
* 连按 Tab(间隔压在底座的连按防抖窗口内)。
|
||||
*
|
||||
* 回归的 bug:跳格是「改选区 → 激活编辑 → 重挂编辑器 → 新编辑器聚焦」的异步链路,
|
||||
* 其中 100ms 防抖 + 一帧渲染期间焦点没有任何落点(`document.activeElement` 是 body)。
|
||||
* 此时再按 Tab,事件源既不在编辑器也不在浮层 —— 若就此放行,浏览器会把焦点按文档
|
||||
* 顺序送出表格,表现就是「按快一点还是掉出表格」。
|
||||
* 另一半成因是起点取错:编辑态的 `focusCol` 被防抖推迟,连按时会算回同一格(原地不动)。
|
||||
*/
|
||||
test('连按 Tab(40ms 间隔):每下一次前进一格,且不跳出表格', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator(WRAP)
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
await wrap.locator('td[data-col-key="amount"]').first().click()
|
||||
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
|
||||
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' })
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await page.keyboard.press('Tab')
|
||||
await page.waitForTimeout(40)
|
||||
}
|
||||
|
||||
// 三下 = 前进三格:amount → status → enabled → remark(防抖只延后编辑器的重挂,不吞按键)
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
|
||||
// 焦点仍在表格里(修前会掉到页面其它元素上)
|
||||
expect(
|
||||
await page.evaluate(() => !!document.activeElement?.closest('[data-testid="table-wrap"]')),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* 编辑行被**虚拟滚动**滚出渲染窗口后按 Tab。
|
||||
*
|
||||
* 这条路径与按键速度无关(实测慢速必现),是「Tab 逃出表格」的另一个入口:
|
||||
* 编辑行滚出渲染窗口 → 编辑器被卸载 → 焦点掉到 `body`;此时按 Tab,事件源既不在
|
||||
* 编辑器也不在浮层 → 若判定「不是给我的」放行,浏览器就按文档顺序把焦点送出表格
|
||||
* (实测落到侧边栏菜单上)。而编辑态与选区其实都还在 —— 滚回来就恢复。
|
||||
*
|
||||
* 宿主是 playground 的 `table-big-wrap`(5000 行,纵向虚拟开启,后两列带 editor)。
|
||||
*/
|
||||
test('编辑行被虚拟滚动滚出窗口后按 Tab:拉回编辑行并前进一格,而不是逃到页面元素上', async ({
|
||||
page,
|
||||
}) => {
|
||||
const BIG = '[data-testid="table-big-wrap"]'
|
||||
await page.goto('/')
|
||||
const big = page.locator(BIG)
|
||||
await big.scrollIntoViewIfNeeded()
|
||||
|
||||
await big.locator('.stk-tbody-main tr[data-row-i="0"] td[data-col-key="name"]').click()
|
||||
await expect(big.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
|
||||
expect(await focusedCell(page)).toEqual({ colKey: 'name', rowIndex: '0' })
|
||||
|
||||
// 纵向滚走:编辑行离开渲染窗口 → 编辑器卸载、焦点回到 body
|
||||
await big.locator('.stk-table').evaluate((el) => {
|
||||
el.scrollTop = 3000
|
||||
})
|
||||
await page.waitForTimeout(300)
|
||||
expect(await page.evaluate(() => document.activeElement === document.body)).toBe(true)
|
||||
expect(
|
||||
await page.evaluate(() => !document.querySelector('[data-testid="table-big-wrap"] .is-cell-editing')),
|
||||
).toBe(true)
|
||||
|
||||
// Tab:必须被接管(修前这里会 preventDefault=false,焦点落到页面其它元素上)
|
||||
await page.keyboard.press('Tab')
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'value', rowIndex: '0' })
|
||||
})
|
||||
|
||||
/**
|
||||
* 按住 Tab 连续走(keydown autoRepeat),且从**只读列**起步。
|
||||
*
|
||||
* 用户实测 100% 复现的手势:在 ERP 里点「订舱号」(只读、没有编辑器)那一格,
|
||||
* 然后一直按着 Tab 往右走,走到「起运港」时就掉出表格了。
|
||||
*
|
||||
* 成因是两条叠加出来的同一个状态 ——「编辑会话在,但 DOM 里一个编辑器都没有」:
|
||||
* · 只读列起步:编辑态是 `editing=true` 而 `focusCol=null`,没有任何编辑器被挂载;
|
||||
* · 底座的**连按 / 长按优化**:处理 repeat 事件时会 `clearActiveEditing()`(把
|
||||
* `editing` 也置 false),长按期间刻意保持 0 编辑器,等松手再挂回来。
|
||||
* 旧判据只认 `editing === true`,于是从第 3 下起就认为「不是给表格的 Tab」而放行,
|
||||
* 浏览器按文档顺序把焦点送出表格。
|
||||
*/
|
||||
test('按住 Tab(autoRepeat)从只读列起步:一格一格前进,不掉出表格', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator(WRAP)
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
|
||||
// 「名称」列没有 editor(对应 ERP 的「订舱号」),点它建立编辑会话
|
||||
await wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="name"]').click()
|
||||
await expect(wrap.locator('.is-cell-editing')).toHaveCount(0)
|
||||
|
||||
// 按住不放:Playwright 对**已按下**的键会把后续 keydown 标成 autoRepeat,与真实长按一致
|
||||
await page.keyboard.down('Tab')
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await page.keyboard.down('Tab')
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
// 长按期间底座刻意保持 0 编辑器 —— 这正是「会话在但没编辑器」那个状态
|
||||
expect(await wrap.locator('.is-cell-editing').count()).toBe(0)
|
||||
await page.keyboard.up('Tab')
|
||||
|
||||
// 四下 = 名称(只读) → 金额 → 状态 → 启用 → 备注;松手后编辑器落到最后一格
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
|
||||
expect(
|
||||
await page.evaluate(() => !!document.activeElement?.closest('[data-testid="table-wrap"]')),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* 复选框与其它单元格**行为一致**:点一下就切换(不需要先激活整行),
|
||||
* 并且这一格随之成为当前格(行高亮 + 选中框 + 编辑会话),键盘也能从这里继续走。
|
||||
*
|
||||
* 回归的 bug:为了「一次点击就切换」,createCheckbox 把 mousedown 停掉了(浏览器在
|
||||
* 节点被替换时不派发 click,不停就会变成「第一次点击只进编辑、第二次才切换」)。
|
||||
* 但停了之后底座收不到 mousedown,也就不会像点击其它格那样设选区、激活该行编辑、
|
||||
* 置当前行 —— 实测后果是:值改了、`change` 也抛了,可整行不高亮、格子没有选中框,
|
||||
* 而且表格里**没有任何选区**,接着按 Tab 会从 (0,0) 起步、焦点掉到 `body`。
|
||||
*/
|
||||
test('点复选框:一次点击即切换,且该格成为当前格(键盘可继续走)', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator(WRAP)
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
const firstRow = wrap.locator('.stk-tbody-main tr').first()
|
||||
const cell = firstRow.locator('td[data-col-key="enabled"]')
|
||||
|
||||
const before = await cell.locator('input').isChecked()
|
||||
// 点可见的复选框(原生 input 藏在 label 里,由 label 承担命中)
|
||||
await cell.locator('label').click()
|
||||
|
||||
// ① 一次点击就切换(这条是原本就正确的行为,别改坏)
|
||||
expect(await cell.locator('input').isChecked()).toBe(!before)
|
||||
|
||||
// ② 这一格成为当前格:行高亮 + 选中框 + 编辑格标记
|
||||
await expect(firstRow).toHaveClass(/active/)
|
||||
await expect(cell).toHaveAttribute('data-cs-s')
|
||||
expect(await cell.evaluate((el) => !!el.querySelector('.is-cell-editing'))).toBe(true)
|
||||
|
||||
// ③ 于是键盘可以从这里继续走:启用 → 备注(同行下一个可编辑列)
|
||||
await page.keyboard.press('Tab')
|
||||
await expect.poll(() => focusedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 斑马纹(`stripe`)与固定列。
|
||||
*
|
||||
* 库把条纹画在 `tr` 上,而冻结列的单元格底色**必须不透明**(否则横向滚动时被压住的
|
||||
* 列会透上来),这层不透明底色会把 `tr` 上的条纹盖住 —— 于是「整表隔行灰、只有序号列没有」。
|
||||
* 组件的做法是在冻结列上补一层**同色内阴影**把条纹接上(见 style/_overrides.scss 的 stripe 段)。
|
||||
*
|
||||
* 所以这里量的是「冻结列单元格上到底有没有那层叠色」,而不是它的 background-color:
|
||||
* 底色必须一直是不透明的 `--td-bgc`(那是冻结列的硬约束),条纹只能是叠上去的。
|
||||
*
|
||||
* 两个宿主覆盖两条 CSS 分支:`table-stripe-wrap`(2 行,纵向虚拟关闭 → `nth-child(even)`)
|
||||
* 与 `table-big-wrap`(5000 行 → `vt-on`,库改取 `nth-child(odd)`)。
|
||||
*/
|
||||
test.describe('G3Table 斑马纹与固定列', () => {
|
||||
/**
|
||||
* 取某一行**第一个单元格**(固定列/序号列)的底色与叠色情况。
|
||||
*
|
||||
* `boxShadow` 里除了叠色还有库的选区描边(`--cs-bc` 未设时是 transparent),
|
||||
* 所以按「有没有非零 alpha 的颜色」判断,而不是比对整串字符串。
|
||||
*/
|
||||
async function fixedCellPaint(
|
||||
page: import('@playwright/test').Page,
|
||||
wrapTestId: string,
|
||||
rowIndex: number,
|
||||
) {
|
||||
return page
|
||||
.locator(`[data-testid="${wrapTestId}"] .stk-tbody-main tr`)
|
||||
.nth(rowIndex)
|
||||
.locator('td')
|
||||
.first()
|
||||
.evaluate((td) => {
|
||||
const cs = getComputedStyle(td)
|
||||
const alphas = [...cs.boxShadow.matchAll(/rgba?\(([^)]+)\)/g)].map((m) => {
|
||||
const parts = m[1].split(',').map((s) => s.trim())
|
||||
return parts.length === 4 ? Number(parts[3]) : 1
|
||||
})
|
||||
const bg = cs.backgroundColor
|
||||
const bgParts = bg.match(/rgba?\(([^)]+)\)/)
|
||||
const bgAlpha = bgParts
|
||||
? (() => {
|
||||
const parts = bgParts[1].split(',').map((s) => s.trim())
|
||||
return parts.length === 4 ? Number(parts[3]) : 1
|
||||
})()
|
||||
: 1
|
||||
return { bg, bgAlpha, maxShadowAlpha: Math.max(0, ...alphas), isFixed: td.classList.contains('fixed-cell') }
|
||||
})
|
||||
}
|
||||
|
||||
test('纵向虚拟关闭时(nth-child(even)):序号列跟着上条纹,底色仍不透明', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-stripe-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
await expect(wrap.locator('.stk-table')).toHaveClass(/stripe/)
|
||||
|
||||
// 第 2 行是条纹行
|
||||
const striped = await fixedCellPaint(page, 'table-stripe-wrap', 1)
|
||||
// 前提:这真的是固定列(序号列注入时就是 fixed: 'left')
|
||||
expect(striped.isFixed).toBe(true)
|
||||
// 关键断言:条纹叠色在(修前这里是 0 —— 条纹被不透明底色盖掉了)
|
||||
expect(striped.maxShadowAlpha).toBeGreaterThan(0)
|
||||
// 且底色仍然不透明:横滚不许透字,这是冻结列的硬约束,不能被条纹改掉
|
||||
expect(striped.bgAlpha).toBe(1)
|
||||
|
||||
// 第 1 行不是条纹行:不应有叠色(叠色只在条纹行上出现)
|
||||
expect((await fixedCellPaint(page, 'table-stripe-wrap', 0)).maxShadowAlpha).toBe(0)
|
||||
})
|
||||
|
||||
test('纵向虚拟开启时(vt-on,库改取 nth-child(odd)):同一条规则要跟着换', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const wrap = page.locator('[data-testid="table-big-wrap"]')
|
||||
await wrap.scrollIntoViewIfNeeded()
|
||||
await expect(wrap.locator('.stk-table')).toHaveClass(/vt-on/)
|
||||
await expect(wrap.locator('.stk-table')).toHaveClass(/stripe/)
|
||||
|
||||
// vt-on 下 DOM 首行是 striped(odd)
|
||||
expect((await fixedCellPaint(page, 'table-big-wrap', 0)).maxShadowAlpha).toBeGreaterThan(0)
|
||||
expect((await fixedCellPaint(page, 'table-big-wrap', 1)).maxShadowAlpha).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -208,10 +208,21 @@ const App = {
|
||||
validate: (v: unknown) => Number(v) > 0 || '金额必须大于 0',
|
||||
},
|
||||
},
|
||||
{ dataIndex: 'status', title: '状态', width: 110, editor: { type: 'select', options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
] } },
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '状态',
|
||||
width: 110,
|
||||
editor: {
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '停用', value: 0 },
|
||||
],
|
||||
// 按行禁用:enabled=0 的那一行状态不许改 —— 用来锁「动态灰底」这条视觉
|
||||
// (e2e 断言第 2 行灰、第 1 行不灰,且都**不需要先进入编辑行**)
|
||||
disabled: (row: { enabled?: number }) => row.enabled === 0,
|
||||
},
|
||||
},
|
||||
{ dataIndex: 'enabled', title: '启用', width: 80, editor: { type: 'checkbox' } },
|
||||
{
|
||||
dataIndex: 'remark',
|
||||
@@ -235,10 +246,12 @@ const App = {
|
||||
const tableSelectedKeys = ref([])
|
||||
|
||||
// 大数据量:用于验证虚拟滚动真的开启
|
||||
// 后两列带 editor:「编辑行被虚拟滚动滚出窗口 → 编辑器卸载 → 焦点掉到 body」这条
|
||||
// 路径只能在这种「行数多到会虚拟化」的表上复现(见 e2e/table.spec.ts 的键盘跳格用例)
|
||||
const bigTableColumns = [
|
||||
{ dataIndex: 'id', title: 'ID', width: 80 },
|
||||
{ dataIndex: 'name', title: '名称', width: 200 },
|
||||
{ dataIndex: 'value', title: '数值', width: 140, align: 'right' },
|
||||
{ dataIndex: 'name', title: '名称', width: 200, editor: { type: 'text' } },
|
||||
{ dataIndex: 'value', title: '数值', width: 140, align: 'right', editor: { type: 'number' } },
|
||||
]
|
||||
const bigTableData = Array.from({ length: 5000 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
@@ -246,6 +259,71 @@ const App = {
|
||||
value: (i + 1) * 7,
|
||||
}))
|
||||
|
||||
/**
|
||||
* 纯展示表(列上**都没有** `editor`)—— 「不可改灰底」门控的宿主。
|
||||
*
|
||||
* 灰底的语义是「这一格现在改不了」,只有**旁边存在可改的格子**时才是信息。
|
||||
* 这类表若逐格铺灰,整张表看上去就是「禁用状态」(文档里 6/7 张 demo 正是纯展示表)。
|
||||
* 判据在 G3Table 的 editableColumnsExist / shouldMarkReadonly,见那边注释。
|
||||
*/
|
||||
const plainColumns = [
|
||||
{ dataIndex: 'code', title: '编号', width: 120 },
|
||||
{ dataIndex: 'name', title: '名称', width: 200 },
|
||||
{ dataIndex: 'remark', title: '备注', width: 200 },
|
||||
]
|
||||
const plainData = ref([
|
||||
{ code: 'A-001', name: '纯展示行', remark: '这一列也不可编辑' },
|
||||
{ code: 'A-002', name: '纯展示行 2', remark: '这一列也不可编辑' },
|
||||
])
|
||||
|
||||
/* ---- Table:固定列 + 校验态(几何回归用例的宿主)---- */
|
||||
/**
|
||||
* 为什么单独造一份数据:
|
||||
* 「校验未通过的格子刚好在固定列上」这个组合出过一次**只有真浏览器才量得出**的
|
||||
* 位移 —— 校验态样式给 td 的 `position: relative` 覆盖了库的固定列定位,
|
||||
* 内联的 `left: 200px` 于是从「sticky 吸附偏移」变成普通相对位移,
|
||||
* 整格被推开「前面所有左固定列宽度之和」。happy-dom 没有布局引擎,锁不住这类问题。
|
||||
*
|
||||
* 两个刻意之处:
|
||||
* · 第 2 行名称为空 → 固定列上必然出现一个 error 格;
|
||||
* · 列宽总和(1680px)超过视口宽度 → 必须横向滚动,固定列才有「固定」可言。
|
||||
*/
|
||||
const fixedTableColumns = [
|
||||
{ dataIndex: 'code', title: '编号(左固定)', width: 200, fixed: 'left' },
|
||||
{
|
||||
dataIndex: 'name',
|
||||
title: '名称(校验列)',
|
||||
width: 200,
|
||||
fixed: 'left',
|
||||
editor: {
|
||||
type: 'text',
|
||||
validate: (v: unknown) => String(v ?? '').length > 0 || '名称不能为空',
|
||||
},
|
||||
},
|
||||
// 填充列:把总宽顶出视口,否则固定列与普通列长得一模一样
|
||||
...Array.from({ length: 6 }, (_, i) => ({
|
||||
dataIndex: `extra${i}`,
|
||||
title: `填充列 ${i + 1}`,
|
||||
width: 180,
|
||||
})),
|
||||
{ dataIndex: 'remark', title: '备注(右固定)', width: 200, fixed: 'right' },
|
||||
]
|
||||
const fixedRowFillers = (row: number) =>
|
||||
Object.fromEntries(Array.from({ length: 6 }, (_, k) => [`extra${k}`, `填充值 ${k + 1}-${row}`]))
|
||||
const fixedTableData = [
|
||||
{ id: 1, code: 'A-001', name: '正常行', remark: '已归档', ...fixedRowFillers(1) },
|
||||
// 空名称 → 这一格必然处于 error 态
|
||||
{ id: 2, code: 'A-002', name: '', remark: '待复核', ...fixedRowFillers(2) },
|
||||
]
|
||||
/**
|
||||
* 当前行按 fms 那套写法交给调用方管(`:active-row-key` + `@row-click`)。
|
||||
*
|
||||
* 这张表专门用来锁「受控调用方点格进入编辑 → 当前行也要高亮」——
|
||||
* 曾经因为浏览器在「单元格 DOM 被替换」时不派发 click,`row-click` 永不触发,
|
||||
* 调用方这个唯一写入者收不到消息,整行不高亮(见 G3Table 的 handleAreaSelectionChange)。
|
||||
*/
|
||||
const fixedActiveKey = ref<string | null>(null)
|
||||
|
||||
return {
|
||||
rtl,
|
||||
toggleRtl,
|
||||
@@ -254,6 +332,11 @@ const App = {
|
||||
tableSelectedKeys,
|
||||
bigTableColumns,
|
||||
bigTableData,
|
||||
plainColumns,
|
||||
plainData,
|
||||
fixedTableColumns,
|
||||
fixedTableData,
|
||||
fixedActiveKey,
|
||||
model,
|
||||
tab,
|
||||
seg,
|
||||
@@ -559,7 +642,9 @@ const App = {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 大数据量(5000 行):纵向虚拟滚动应当开启;容器限高以产生滚动 -->
|
||||
<!-- 大数据量(5000 行):纵向虚拟滚动应当开启;容器限高以产生滚动。
|
||||
顺带开斑马纹:纵向虚拟时库改取 nth-child(odd)(DOM 首行≈偶数数据行),
|
||||
冻结列的条纹叠色必须跟着同一套 nth-child 走(见 _overrides.scss 的 stripe 段)。 -->
|
||||
<div data-testid="table-big-wrap" style="height: 360px; margin-top: 16px">
|
||||
<G3Table
|
||||
data-testid="table-big"
|
||||
@@ -567,6 +652,7 @@ const App = {
|
||||
:data-source="bigTableData"
|
||||
:seq="false"
|
||||
:row-height="30"
|
||||
stripe
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -579,6 +665,39 @@ const App = {
|
||||
:seq="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 固定列 + 校验态 + 受控当前行:几何与「点格进编辑要高亮」的回归宿主 -->
|
||||
<div data-testid="table-fixed-wrap" style="height: 240px; margin-top: 16px">
|
||||
<G3Table
|
||||
data-testid="table-fixed"
|
||||
:columns="fixedTableColumns"
|
||||
:data-source="fixedTableData"
|
||||
:active-row-key="fixedActiveKey"
|
||||
fixed-col-shadow
|
||||
@row-click="(_, row) => (fixedActiveKey = String(row.id))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 纯展示表(列上都没有 editor):不该铺「不可改」灰底 -->
|
||||
<div data-testid="table-plain-wrap" style="height: 200px; margin-top: 16px">
|
||||
<G3Table data-testid="table-plain" :columns="plainColumns" :data-source="plainData" />
|
||||
</div>
|
||||
|
||||
<!-- 关闭编辑(editable: false),但列上声明了 editor:同样不该铺灰底 -->
|
||||
<div data-testid="table-noedit-wrap" style="height: 200px; margin-top: 16px">
|
||||
<G3Table
|
||||
data-testid="table-noedit"
|
||||
:columns="tableColumns"
|
||||
:data-source="tableData"
|
||||
:editable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 斑马纹(stripe):固定列(序号列)也要跟着上条纹。
|
||||
用纯展示列(没有 editor)当宿主,避免「不可改灰底」盖住条纹干扰断言。 -->
|
||||
<div data-testid="table-stripe-wrap" style="height: 200px; margin-top: 16px">
|
||||
<G3Table data-testid="table-stripe" :columns="plainColumns" :data-source="plainData" stripe />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -178,3 +178,110 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 固定列:不透明底色 + 行状态叠色(防横向滚动时透出下方内容)
|
||||
// --------------------------------------------------------------------------
|
||||
// 症状:鼠标悬停、或正在编辑(当前行)的那一行里,**冻结列是半透明的** ——
|
||||
// 被横向滚过去的列从冻结列底下透出来(用户报的「客户这列是透明的」;
|
||||
// 静止时不易发现,一横滚就是「透字」)。
|
||||
//
|
||||
// 根因:库给固定列的是 `background-color: inherit`(继承 tr 的行底色),
|
||||
// 而行的 hover / 当前行底色是 **alpha 值**:
|
||||
// --tr-hover-bgc = rgba(0, 0, 0, 0.04)(令牌 fill 就是 alpha)
|
||||
// --tr-active-bgc = color-primary-soft(应用皮肤常给 hsl(... / 0.15))
|
||||
// 非固定列继承它没问题 —— tr 底下就是表格基色,合成结果是不透明的;
|
||||
// 冻结列却在**被横滚的列之上**,它自己必须不透明。
|
||||
//
|
||||
// 修法:冻结列底色写死成不透明的 `--td-bgc`(亮 #fff / 暗 #141414),
|
||||
// 行状态再用**内阴影**叠一层同色。为什么是内阴影而不是别的:
|
||||
// · 不能用 `background-image` 叠渐变 —— 会压掉库用 background-image
|
||||
// 画的边框渐变(本文件表头那节已踩过这个坑,见上方 ⚠);
|
||||
// · 不能用伪元素 —— 绝对定位的伪元素要求 td 是定位元素,而固定列的
|
||||
// `position` 被库占着(sticky + 内联 left/right),static 时伪元素
|
||||
// 会挂到外层容器上、把整表涂色;
|
||||
// · 内阴影画在**背景之上、内容之下**:不遮文字、不动几何、不需要定位,
|
||||
// 半透明色叠在不透明底上,观感与整行一致。
|
||||
//
|
||||
// 只覆盖 hover 与当前行两个状态:拖拽掠过 / 区域选择行用的是另外的变量
|
||||
// (--tr-drag-over / --rs-bgc),本规则不动它们,保持库的默认表现。
|
||||
//
|
||||
// ⚠️ 叠色必须**带上库的选区描边**(下面的 $cs-border):
|
||||
// `box-shadow` 是单值属性,只写叠色那一条会把库画在 td 上的选中框整体替换掉,
|
||||
// 症状是「冻结列上的选中格(正在编辑那一格)没有蓝色选中边」——非冻结列没有
|
||||
// 本规则,所以只有冻结列会缺,很容易被当成「选中态没生效」。
|
||||
// 库的原式:`.stk-table td[data-cs-s] { box-shadow: inset 0 var(--cs-t,0) 0 0
|
||||
// var(--cs-bc), … }`,其中 --cs-t/b/l/r 由库按「这条边是不是选区边界」内联给出
|
||||
// (非选区边界为 0),--cs-bc 是选区描边色(主题变量)。
|
||||
// 顺序:先写描边、后写叠色 —— box-shadow 里**前面的画在上层**,叠色若排在后面
|
||||
// 才压不住描边。
|
||||
$cs-border: inset 0 var(--cs-t, 0) 0 0 var(--cs-bc, transparent),
|
||||
inset 0 var(--cs-b, 0) 0 0 var(--cs-bc, transparent),
|
||||
inset var(--cs-l, 0) 0 0 0 var(--cs-bc, transparent),
|
||||
inset var(--cs-r, 0) 0 0 0 var(--cs-bc, transparent);
|
||||
|
||||
// 编辑格**不参与**行状态叠色:它与普通列的编辑格必须长得一样(纯基色 + 焦点环)。
|
||||
// 普通列的编辑格由 G3Table 的规则刷成不透明的 `--td-bgc`、不叠行色;冻结列若再叠一层,
|
||||
// 同一行里就会出现「冻结列的编辑格有色、普通列的编辑格纯色」这种一眼可见的不一致。
|
||||
$editing-cell: ':not(:has(> .#{e('table', 'cell-editor-root')}.#{is('cell-editing')}))';
|
||||
|
||||
// 「这一行是条纹行,且没有别的行状态接管它的底色」——供下面的 stripe 段复用。
|
||||
// 判据与库一致:条纹行由库按 nth-child 决定(纵向虚拟时取 odd,见 style 里的说明);
|
||||
// 不含 :hover / .active / [data-rs-s]:这些状态是**替换**行底色,条纹该让位。
|
||||
$striped-row: ':not(:hover):not(.active):not([data-rs-s])';
|
||||
|
||||
.#{b('table')} {
|
||||
.#{e('table', 'inner')}.stk-table {
|
||||
.stk-tbody-main td.fixed-cell {
|
||||
background-color: var(--td-bgc, #{v('bg-container', #fff)});
|
||||
}
|
||||
|
||||
// 与库同一判据:hover 底色本身也是被 .row-hover 门控的(选择器照抄库那条)
|
||||
&.row-hover .stk-tbody-main tr:hover > td.fixed-cell#{$editing-cell} {
|
||||
box-shadow: $cs-border, inset 0 0 0 100vmax var(--tr-hover-bgc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当前行(.active):**必须带上 `.row-active`**,否则权重低于上面那条 hover
|
||||
// (`:hover` 是伪类,和类同档 —— hover 那条 (0,7,2),不带 row-active 的这条
|
||||
// 只有 (0,6,2)),会出现「当前行 + 鼠标悬停时,冻结列显示的是 hover 色」。
|
||||
// 写成 (0,7,2) 与其平手,再靠源序(本块在其后)取胜。
|
||||
.#{b('table')} {
|
||||
.#{e('table', 'inner')}.stk-table.row-active {
|
||||
.stk-tbody-main tr.active > td.fixed-cell#{$editing-cell} {
|
||||
box-shadow: $cs-border, inset 0 0 0 100vmax var(--tr-active-bgc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 斑马纹(stripe):冻结列必须自己接上条纹
|
||||
//
|
||||
// 库只把条纹画在 `tr` 上(`.stk-table.stripe … .stk-tbody-main tr:nth-child(even)`
|
||||
// 用 background-color: var(--stripe-bgc)),而冻结列的单元格被上面那条规则
|
||||
// 写死成**不透明的** `--td-bgc` —— 于是条纹被盖住,**只有冻结列没有斑马纹**
|
||||
// (序号列首当其冲;宿主页自己有行斑马纹样式时症状一模一样,那是文档站踩到的
|
||||
// VitePress `.vp-doc tr:nth-child(2n)` 泄漏)。
|
||||
//
|
||||
// 修法与行状态同款:不透明底 + 内阴影叠条纹色。
|
||||
// ⚠️ 不能直接把 background-color 设成 `--stripe-bgc` —— 那是 **alpha** 色
|
||||
// (亮 rgba(0,0,0,.02) / 暗 rgba(255,255,255,.04)),拿它当底色等于把冻结列
|
||||
// 变回半透明,正是本文件开头那个「横滚透字」bug。
|
||||
//
|
||||
// ⚠️ 排除三种行:`:hover` / `.active` 由上面的内阴影叠色表达,再压一层条纹会让
|
||||
// 冻结列比同行普通列更暗(库的 hover / 当前行是**替换** tr 底色,不是叠加);
|
||||
// `[data-rs-s]`(区域选择行)用 --rs-bgc,本规则不参与,保持库的默认表现。
|
||||
// 编辑格同样排除:它要与普通列的编辑格长得一样(纯基色 + 焦点环)。
|
||||
//
|
||||
// ⚠️ 必须带 $cs-border:box-shadow 是单值属性,只写叠色会把库画在 td 上的选区
|
||||
// 描边整体替换掉(症状:冻结列上的选中格没有蓝色选中边)。
|
||||
// --------------------------------------------------------------------------
|
||||
.#{b('table')} {
|
||||
.#{e('table', 'inner')}.stk-table.stripe {
|
||||
&:not(.vt-on) .stk-tbody-main tr:nth-child(even)#{$striped-row} > td.fixed-cell#{$editing-cell},
|
||||
&.vt-on .stk-tbody-main tr:nth-child(odd)#{$striped-row} > td.fixed-cell#{$editing-cell} {
|
||||
box-shadow: $cs-border, inset 0 0 0 100vmax var(--stripe-bgc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +74,14 @@ export interface TableEditor<T extends TableRow = TableRow> {
|
||||
validateWarning?: (value: unknown, row: T, col: G3TableColumn<T>) => boolean | string
|
||||
/**
|
||||
* 校验态的**展示**形态,与 validate 正交:
|
||||
* - 不传(默认):在单元格(td)上画「浅红底 + 右上角 ! 角标」。
|
||||
* - `'inline'`:只在编辑器自身画 1px 内描边 + 文字色,不铺底、不加角标。
|
||||
* - 不传(默认):在单元格(td)上铺「浅红底 / 浅琥珀底」;**正在编辑的那一格例外**——
|
||||
* 底色回到基色(铺底会让输入区变暗难读),校验信号改由语义色焦点环 + 红字承载。
|
||||
* - `'inline'`:只在编辑器自身画 1px 内描边 + 文字色,不铺底(任何状态下都不铺)。
|
||||
*
|
||||
* 为什么留这个开关:浅红底来自设计原型 `.cell.is-error`,适合 ERP 明细这类
|
||||
* **逐格录入**的场景;但密度极高的只读表偶尔需要「很轻」的提示,
|
||||
* 一铺底会把整行色带切碎。两种都是既有行为,用枚举而不是布尔,
|
||||
* 便于将来再加 'warning-badge' 之类而不用改签名。
|
||||
* 便于将来再加第三种形态而不用改签名。
|
||||
*
|
||||
* 注意:无论哪种形态,**文字色变红都照旧生效**(见 G3Table.vue 的 g3-input--error 规则)。
|
||||
*/
|
||||
@@ -226,6 +227,22 @@ export interface TableProps<T extends TableRow = TableRow> {
|
||||
headerRowHeight?: number
|
||||
/** 边框:true=外框圆角描边+行分隔线;false=无外框仅行分隔线 */
|
||||
bordered?: boolean | string
|
||||
/**
|
||||
* 斑马纹(隔行底色),默认 `false`。透传给底座的 `stripe`。
|
||||
*
|
||||
* ⚠️ 冻结列要单独接:行底色由库画在 `tr` 上,而冻结列的单元格必须是**不透明**底色
|
||||
* (否则横向滚动时被压住的内容会透字),它自己那层底色会把 `tr` 上的条纹盖掉 ——
|
||||
* 于是组件在冻结列上补一层同色内阴影把条纹接上(见 `style/_overrides.scss`)。
|
||||
* 这也是「宿主页自己有行斑马纹样式时,只有序号列没条纹」的成因。
|
||||
*/
|
||||
stripe?: boolean
|
||||
/**
|
||||
* 固定列被滚动内容遮挡时是否画阴影,默认 false。
|
||||
*
|
||||
* 纯观感开关(没有固定列时什么都不画),但冻结列与滚过去的列同为白底、
|
||||
* 紧贴在一起时,没有这层 10px 渐变就看不出边界,业务侧建议打开。
|
||||
*/
|
||||
fixedColShadow?: boolean
|
||||
/** 列宽拖拽 */
|
||||
colResizable?: boolean | TableColResizableConfig<T>
|
||||
/** 列序拖拽 */
|
||||
|
||||
@@ -104,12 +104,22 @@ export function resolveCustomCell<T extends TableRow>(
|
||||
* 没配校验的 text/number 列仍保持原样——非编辑态零组件开销。
|
||||
*/
|
||||
const hasValidation = Boolean(editor?.validate || editor?.validateWarning)
|
||||
/**
|
||||
* `editor.disabled` 写成**函数**时(「这一行这一列能不能改」随行数据变),
|
||||
* 常驻注入:非编辑行也要给这些格子打上「不可改」标记,
|
||||
* 否则灰底只能在被点过的那一行里看到,用户仍要一行行点过去才发现某格点不动。
|
||||
*
|
||||
* 代价是这些列的非编辑格也会多一个函数式组件实例 —— 只发生在**显式用了
|
||||
* 按行禁用**的列上(普通 text/number 列照旧零开销)。
|
||||
*/
|
||||
const hasRowDisabled = typeof editor?.disabled === 'function'
|
||||
if (
|
||||
editor &&
|
||||
(editingActive ||
|
||||
editor.type === 'checkbox' ||
|
||||
editor.type === 'select' ||
|
||||
hasValidation)
|
||||
hasValidation ||
|
||||
hasRowDisabled)
|
||||
) {
|
||||
return {
|
||||
...col,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createEditableCell, type EditableContext } from './editable'
|
||||
import { createEditableCell, findAdjacentEditableCell, type EditableContext } from './editable'
|
||||
import type { G3TableColumn, TableRow } from '../types'
|
||||
|
||||
interface Row extends TableRow {
|
||||
@@ -23,12 +23,15 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
|
||||
blurred: boolean[]
|
||||
/** revertRow 被调用的行 id 列表(Esc 回滚) */
|
||||
reverted: number[]
|
||||
/** onEditorInteract 收到的 (行, 列) —— checkbox 切换后要把该格同步成当前格 */
|
||||
interacted: Array<{ row: Row; col: string }>
|
||||
notified: number
|
||||
} {
|
||||
const changes: Array<{ row: Row; value: unknown }> = []
|
||||
const focused: number[] = []
|
||||
const blurred: number[] = []
|
||||
const reverted: number[] = []
|
||||
const interacted: Array<{ row: Row; col: string }> = []
|
||||
let notified = 0
|
||||
const ctx: EditableContext<Row> = {
|
||||
isEditing: () => true,
|
||||
@@ -37,6 +40,7 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
|
||||
consumePendingFocus: () => true,
|
||||
onEditorFocus: (row) => focused.push(row.id),
|
||||
onEditorBlur: (row) => blurred.push(row.id),
|
||||
onEditorInteract: (row, col) => interacted.push({ row, col: col.dataIndex }),
|
||||
/**
|
||||
* 无缝编辑器形态:G3Table 的 editorSeamless 默认开启。
|
||||
*
|
||||
@@ -46,6 +50,12 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
|
||||
* 而不是一个测试专用的特例。
|
||||
*/
|
||||
isSeamless: () => true,
|
||||
/**
|
||||
* 「不可改的格子」标记的总开关:桩里默认开启(= 一张有可编辑列的、编辑功能打开的表),
|
||||
* 于是下面那些断言测的是**默认行为** —— 单独特例(纯展示表 / editable: false)
|
||||
* 由各自的用例显式覆盖。
|
||||
*/
|
||||
shouldMarkReadonly: () => true,
|
||||
shouldNotify: () => {
|
||||
notified += 1
|
||||
return true
|
||||
@@ -63,6 +73,7 @@ function makeCtx(overrides: Partial<EditableContext<Row>> = {}): {
|
||||
focused,
|
||||
blurred,
|
||||
reverted,
|
||||
interacted,
|
||||
// 用 getter 读实时值
|
||||
get notified() {
|
||||
return notified
|
||||
@@ -80,6 +91,26 @@ function render(col: G3TableColumn<Row>, ctx: EditableContext<Row>, row: Row) {
|
||||
return cell({ row })
|
||||
}
|
||||
|
||||
/**
|
||||
* 展平 vnode 上的 class(可能是字符串、数组、或 `{ 类名: 布尔 }` 对象)。
|
||||
*
|
||||
* 编辑器把状态类(校验 / 只读 / 当前格)都写成对象形式,直接断言 `props.class`
|
||||
* 会得到一堆难读的嵌套结构 —— 展平成字符串数组后 `toContain('类名')` 才是可读的。
|
||||
*/
|
||||
function classesOf(vnode: { props?: Record<string, unknown> }) {
|
||||
const raw = vnode.props?.class as unknown
|
||||
const list = Array.isArray(raw) ? raw : [raw]
|
||||
return list.flatMap((item) => {
|
||||
if (typeof item === 'string') return item.split(/\s+/).filter(Boolean)
|
||||
if (item && typeof item === 'object') {
|
||||
return Object.entries(item as Record<string, unknown>)
|
||||
.filter(([, on]) => Boolean(on))
|
||||
.map(([name]) => name)
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
describe('非编辑态:走纯展示路径', () => {
|
||||
it('渲染成 span,不挂任何编辑器组件', () => {
|
||||
const col = makeColumn({ editor: { type: 'text' } } as never)
|
||||
@@ -178,7 +209,7 @@ describe('编辑态:input 编辑器', () => {
|
||||
*
|
||||
* 这一组盯的是「错误态的**判据**是什么」,因为它是本功能最容易回归的点:
|
||||
* 样式锚在 `is-editor-error` 类上,而这个类**必须**在 input / select / checkbox
|
||||
* 三条路径上都出现 —— 一旦哪条漏了,那一列的「浅红底 + 角标」会静默失效
|
||||
* 三条路径上都出现 —— 一旦哪条漏了,那一列的「浅红底」会静默失效
|
||||
* (CSS 不报错,只是不命中)。
|
||||
*
|
||||
* 另一个重点是**派生性**:状态由当前行数据实时求出,不是需要业务维护的
|
||||
@@ -251,13 +282,13 @@ describe('校验态:editor.validate', () => {
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-error')
|
||||
})
|
||||
|
||||
it('errorStyle: "inline" 时不打错误类(只要编辑器自身内描边,不要铺底与角标)', () => {
|
||||
it('errorStyle: "inline" 时不打错误类(只要编辑器自身内描边,不要铺底)', () => {
|
||||
const col = makeColumn({
|
||||
editor: { type: 'text', validate: () => '错误', errorStyle: 'inline' },
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
const vnode = render(col, ctx, { id: 1, name: '', status: 1 })
|
||||
// 不打类 → 单元格不铺底、不出角标;但 status 仍传,文字色与 aria 还在
|
||||
// 不打类 → 单元格不铺底;但 status 仍传,文字色与 aria 还在
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-error')
|
||||
expect(vnode.props.status).toBe('error')
|
||||
})
|
||||
@@ -316,7 +347,7 @@ describe('校验态:editor.validate', () => {
|
||||
expect(classesOf(vnode)).not.toContain('is-editor-warning')
|
||||
})
|
||||
|
||||
it('errorStyle: "inline" 时 warning 类同样不打(inline 形态不铺底不加角标)', () => {
|
||||
it('errorStyle: "inline" 时 warning 类同样不打(inline 形态不铺底)', () => {
|
||||
const col = makeColumn({
|
||||
editor: { type: 'text', validateWarning: () => '提醒', errorStyle: 'inline' },
|
||||
} as never)
|
||||
@@ -506,6 +537,42 @@ describe('checkbox 编辑器', () => {
|
||||
expect(row.status).toBe(0)
|
||||
expect(changes[0].value).toBe(0)
|
||||
})
|
||||
|
||||
/**
|
||||
* checkbox 是**常驻可直接操作**的编辑器(mousedown 被 stop 掉了),
|
||||
* 底座不会像点击其它格那样把这一格设为当前格 —— 所以切换之后必须显式通知表格,
|
||||
* 否则「数据改了、但整行不高亮、格子没有选中框,且表格里没有任何选区」(实测)。
|
||||
*/
|
||||
it('切换后通知表格「这一格成为当前格」(与点击其它格行为一致)', () => {
|
||||
const row = { id: 1, name: 'x', status: 0 }
|
||||
const { ctx, interacted } = makeCtx()
|
||||
const vnode = render(col, ctx, row)
|
||||
const input = (vnode.children as Array<{ props: Record<string, unknown> }>)[0]
|
||||
|
||||
;(input.props.onChange as (e: unknown) => void)({ target: { checked: true } })
|
||||
expect(interacted).toEqual([{ row, col: 'status' }])
|
||||
})
|
||||
|
||||
it('仅仅渲染不会触发它(只有用户操作才算)', () => {
|
||||
const { ctx, interacted } = makeCtx()
|
||||
render(col, ctx, { id: 1, name: 'x', status: 0 })
|
||||
expect(interacted).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('Enter 切换(键盘路径)同样通知表格', () => {
|
||||
const row = { id: 1, name: 'x', status: 1 }
|
||||
const { ctx, interacted } = makeCtx()
|
||||
const vnode = render(col, ctx, row)
|
||||
const input = (vnode.children as Array<{ props: Record<string, unknown> }>)[0]
|
||||
|
||||
;(input.props.onKeydown as (e: unknown) => void)({
|
||||
key: 'Enter',
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
currentTarget: { checked: true },
|
||||
})
|
||||
expect(interacted).toEqual([{ row, col: 'status' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('禁用与字段级开关', () => {
|
||||
@@ -526,6 +593,31 @@ describe('禁用与字段级开关', () => {
|
||||
expect(typeof render(col, ctx, { id: 2, name: 'b', status: 1 }).type).toBe('object')
|
||||
})
|
||||
|
||||
it('按行禁用时,展示态的 span 带 cell-readonly(表格侧据此铺灰底)', () => {
|
||||
const col = makeColumn({
|
||||
editor: { type: 'text', disabled: (row: Row) => row.id === 1 },
|
||||
} as never)
|
||||
const { ctx } = makeCtx()
|
||||
expect(classesOf(render(col, ctx, { id: 1, name: 'a', status: 1 }))).toContain(
|
||||
'g3-table__cell-readonly',
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 灰底的语义是「这一格现在改不了」,只有**旁边存在可改的格子**时才是信息。
|
||||
* 纯展示表(没有任何可编辑列)/ `editable: false` 时由 G3Table 关掉这个标记 ——
|
||||
* 否则整表逐格铺灰,看上去像「禁用状态」(文档里 6/7 张 demo 正是纯展示表)。
|
||||
*/
|
||||
it('总开关关闭(纯展示表 / editable: false)时不打 cell-readonly', () => {
|
||||
const col = makeColumn({
|
||||
editor: { type: 'text', disabled: (row: Row) => row.id === 1 },
|
||||
} as never)
|
||||
const { ctx } = makeCtx({ shouldMarkReadonly: () => false })
|
||||
const span = render(col, ctx, { id: 1, name: 'a', status: 1 })
|
||||
expect(span.type).toBe('span')
|
||||
expect(classesOf(span)).not.toContain('g3-table__cell-readonly')
|
||||
})
|
||||
|
||||
it('editor.onChange 先于上下文的 onChange 触发', () => {
|
||||
const order: string[] = []
|
||||
const col = makeColumn({
|
||||
@@ -724,3 +816,100 @@ describe('键盘:Enter 与 Esc', () => {
|
||||
expect(again.props.modelValue).toBe('原始')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Tab 跳格的目标计算。
|
||||
*
|
||||
* 这一组锁的是「跳过不可编辑格 + 跨行回绕」这条规则本身 —— 它**刻意**不同于底座的
|
||||
* 「列 ±1」:底座会把焦点送到只读格上(编辑器在那里不存在,表现为按下 Tab 后焦点消失),
|
||||
* 而这里只落在真能接收焦点的格上。
|
||||
*/
|
||||
describe('Tab 跳格:findAdjacentEditableCell', () => {
|
||||
const textCol = (dataIndex: string) =>
|
||||
({ dataIndex, title: dataIndex, editor: { type: 'text' } }) as G3TableColumn<Row>
|
||||
/** 只读列(没有 editor):Tab 必须跳过它 */
|
||||
const readonlyCol = (dataIndex: string) => ({ dataIndex, title: dataIndex }) as G3TableColumn<Row>
|
||||
|
||||
const rows: Row[] = [
|
||||
{ id: 1, name: 'a', status: 1 },
|
||||
{ id: 2, name: 'b', status: 0 },
|
||||
]
|
||||
|
||||
it('前进:跳过中间没有编辑器的列', () => {
|
||||
const cols = [textCol('id'), readonlyCol('name'), textCol('status')]
|
||||
expect(findAdjacentEditableCell(rows, cols, { rowIndex: 0, colIndex: 0 }, 1)).toEqual({
|
||||
rowIndex: 0,
|
||||
colIndex: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('行尾回绕到下一行第一个可编辑格(Excel 手感)', () => {
|
||||
const cols = [readonlyCol('id'), textCol('name'), textCol('status')]
|
||||
// 第一行最后一列 → 第二行的 name(下一行的 id 是只读,跳过)
|
||||
expect(findAdjacentEditableCell(rows, cols, { rowIndex: 0, colIndex: 2 }, 1)).toEqual({
|
||||
rowIndex: 1,
|
||||
colIndex: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('后退(Shift+Tab):方向反过来,同样跳只读格并跨行回绕', () => {
|
||||
const cols = [readonlyCol('id'), textCol('name'), textCol('status')]
|
||||
// 第二行第一列(readonly)再往前 → 第一行的 status
|
||||
expect(findAdjacentEditableCell(rows, cols, { rowIndex: 1, colIndex: 1 }, -1)).toEqual({
|
||||
rowIndex: 0,
|
||||
colIndex: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('按行禁用(disabled 函数为真)的格也跳过 —— 那里的编辑器根本不渲染', () => {
|
||||
const cols = [
|
||||
textCol('name'),
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '状态',
|
||||
editor: { type: 'select', options: [], disabled: (row: Row) => row.status === 0 },
|
||||
} as G3TableColumn<Row>,
|
||||
textCol('id'),
|
||||
]
|
||||
// 第一行 status=1 可编辑 → 正常落到 status
|
||||
expect(findAdjacentEditableCell(rows, cols, { rowIndex: 0, colIndex: 0 }, 1)).toEqual({
|
||||
rowIndex: 0,
|
||||
colIndex: 1,
|
||||
})
|
||||
// 第二行 status=0 被禁用 → 越过它落到再后面一列
|
||||
expect(findAdjacentEditableCell(rows, cols, { rowIndex: 1, colIndex: 0 }, 1)).toEqual({
|
||||
rowIndex: 1,
|
||||
colIndex: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('到头 / 到底:循环回另一端(不交给浏览器原生 Tab)', () => {
|
||||
const cols = [readonlyCol('id'), textCol('name')]
|
||||
// 全表最后一个可编辑格再往前 → 回到全表第一个可编辑格
|
||||
expect(findAdjacentEditableCell(rows, cols, { rowIndex: 1, colIndex: 1 }, 1)).toEqual({
|
||||
rowIndex: 0,
|
||||
colIndex: 1,
|
||||
})
|
||||
// 全表第一个可编辑格再往后 → 落到全表最后一个可编辑格
|
||||
expect(findAdjacentEditableCell(rows, cols, { rowIndex: 0, colIndex: 1 }, -1)).toEqual({
|
||||
rowIndex: 1,
|
||||
colIndex: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('全表只有一格可编辑:循环结果就是它自己(不会跳到只读格上)', () => {
|
||||
const single: Row[] = [{ id: 1, name: 'a', status: 1 }]
|
||||
const cols = [readonlyCol('id'), textCol('name')]
|
||||
expect(findAdjacentEditableCell(single, cols, { rowIndex: 0, colIndex: 1 }, 1)).toEqual({
|
||||
rowIndex: 0,
|
||||
colIndex: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('一个可编辑格都没有时返回 null(不抛错):调用方据此放行原生 Tab', () => {
|
||||
expect(findAdjacentEditableCell(rows, [], { rowIndex: 0, colIndex: 0 }, 1)).toBeNull()
|
||||
expect(
|
||||
findAdjacentEditableCell(rows, [readonlyCol('id'), readonlyCol('name')], { rowIndex: 0, colIndex: 0 }, 1),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -48,6 +48,14 @@ export interface EditableContext<T extends TableRow> {
|
||||
* 单元格内无外间距、编辑器无常驻边框,只有焦点 / 校验 / 特殊交互态给提示。
|
||||
*/
|
||||
isSeamless: () => boolean
|
||||
/**
|
||||
* 「不可改的格子」标记(`cell-readonly` → 常驻灰底)要不要生效。
|
||||
*
|
||||
* 灰底的语义是「这一格现在改不了」,只有**旁边存在可改的格子**时它才是信息。
|
||||
* 纯展示表(列上都没有 `editor`)里每格都改不了,逐格铺灰会让整表看上去像禁用状态;
|
||||
* `editable === false` 同理。两种情况都由 G3Table 的这个判据挡掉(详见其实现注释)。
|
||||
*/
|
||||
shouldMarkReadonly: () => boolean
|
||||
/** 值真正变化时才返回 true(用于去重通知) */
|
||||
shouldNotify: (row: T, col: G3TableColumn<T>, value: unknown) => boolean
|
||||
/** 通知父级 */
|
||||
@@ -60,6 +68,17 @@ export interface EditableContext<T extends TableRow> {
|
||||
* 返回是否真的发生了回滚(值本来就没变则返回 false,避免无谓 emit)。
|
||||
*/
|
||||
revertRow: (row: T) => boolean
|
||||
/**
|
||||
* 「用户用了这一格的编辑器」→ 把该格同步成表格的当前格(选区 + 激活编辑 + 当前行)。
|
||||
*
|
||||
* 只有 checkbox 需要它:它是**常驻可直接操作**的编辑器(见 createCheckbox 里
|
||||
* `stop` 掉 mousedown 的原因),底座因此收不到 mousedown,也就不会像点击其它格那样
|
||||
* 把这一格设为当前格。不补这一下的后果(都是实测):
|
||||
* · 数据已经改了(`change` 也抛出去了),但整行不高亮、格子没有选中框,看不出在哪儿改;
|
||||
* · 表格里**一个选区都没有**,接着按 Tab / 方向键会从 (0,0) 起步、焦点掉到 body。
|
||||
* 其它编辑器不需要:它们的格子本来就得先点进编辑(那条路径已经同步过当前格)。
|
||||
*/
|
||||
onEditorInteract?: (row: T, col: G3TableColumn<T>) => void
|
||||
}
|
||||
|
||||
/* ---------- 草稿 ---------- */
|
||||
@@ -125,7 +144,7 @@ function isEditorDisabled<T extends TableRow>(editor: TableEditor<T>, row: T): b
|
||||
*
|
||||
* 每次渲染按当前行数据**重新求值**,这是本实现最重要的性质:
|
||||
* 校验结果是行数据的**派生值**,不是需要业务手动维护的一次性状态。
|
||||
* 用户把空的数量改成 12 → 下一次渲染 validate 返回 true → 红底与角标自动消失,
|
||||
* 用户把空的数量改成 12 → 下一次渲染 validate 返回 true → 红底自动消失,
|
||||
* 不需要业务调任何 clearError(),组件内部也不留任何 error 注册表
|
||||
* (注册表还要处理行被删除 / 数据源整体替换时的清理,是纯粹的负债)。
|
||||
*
|
||||
@@ -229,7 +248,7 @@ function notifyChange<T extends TableRow>(
|
||||
/**
|
||||
* 非编辑态的展示内容:select 需要把值解析成 label。
|
||||
*
|
||||
* ⚠️ 展示态**同样要渲染校验态**(红底/琥珀底 + ! 角标)。
|
||||
* ⚠️ 展示态**同样要渲染校验态**(红底 / 琥珀底)。
|
||||
*
|
||||
* 原因是很实际的一条:编辑器只在**被点击过的那一行**才挂载
|
||||
* (见 column.ts 的 resolveCustomCell 门控),如果校验态只在编辑器里求值,
|
||||
@@ -238,12 +257,13 @@ function notifyChange<T extends TableRow>(
|
||||
*
|
||||
* 校验是**数据的状态**,不该依赖「哪一行被交互过」。
|
||||
* 所以这里在展示态也求一次 status,把它作为状态类吐在 span 上,
|
||||
* 表格侧的 `:has()` 选择器就能命中并铺底 + 出角标。
|
||||
* 表格侧的 `:has()` 选择器就能命中并铺底。
|
||||
*
|
||||
* 为什么不在展示态也挂编辑器:那正是 resolveCustomCell 极力避免的开销
|
||||
* (滚动时每格一个函数式组件)。这里只多打一个 class,成本可忽略。
|
||||
*/
|
||||
function displayValue<T extends TableRow>(
|
||||
ctx: EditableContext<T>,
|
||||
row: T,
|
||||
col: G3TableColumn<T>,
|
||||
editor: TableEditor<T>,
|
||||
@@ -258,6 +278,22 @@ function displayValue<T extends TableRow>(
|
||||
const content = text ?? ''
|
||||
const status = resolveEditorStatus(editor, row, col)
|
||||
const statusClass = editorStatusClass(status, editor.errorStyle, is)
|
||||
/**
|
||||
* 按行动态禁用的格子:这里挂「不可改」标记,表格侧据此常驻灰底。
|
||||
*
|
||||
* 为什么标记打在 span 上:JS 侧给 `td` 加**按行变化**的类做不到
|
||||
* (见 column.ts 的注释:库渲染 td 时只取 `col.className`,那是整列常量)。
|
||||
* 而「这一行这一列能不能改」是行数据的派生值,只能由内容节点带出来,
|
||||
* 表格侧再用 `td:has(> .cell-readonly)` 反查。
|
||||
*
|
||||
* 静态不可改的列(没有 editor / `disabled: true`)走另一条更便宜的路:
|
||||
* 由 G3Table 直接往 `col.className` 上写同一个类名,一个字符串搞定,
|
||||
* 不为它们注入 customCell(见 column.ts 的性能注释)。
|
||||
*
|
||||
* 前置条件 `shouldMarkReadonly()`:纯展示表 / `editable: false` 时不标 ——
|
||||
* 否则整表逐格铺灰、看上去像禁用状态(见该成员在 EditableContext 里的说明)。
|
||||
*/
|
||||
const readonly = ctx.shouldMarkReadonly() && isEditorDisabled(editor, row) ? e('cell-readonly') : ''
|
||||
return h(
|
||||
'span',
|
||||
{
|
||||
@@ -273,6 +309,7 @@ function displayValue<T extends TableRow>(
|
||||
* 而本文件已经有过「改一处忘另一处」的教训。
|
||||
*/
|
||||
statusClass ? { [statusClass]: true } : {},
|
||||
readonly ? { [readonly]: true } : {},
|
||||
],
|
||||
title: String(content),
|
||||
},
|
||||
@@ -310,8 +347,88 @@ function createFocusHandler<T extends TableRow>(
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Tab 跳格 ---------- */
|
||||
|
||||
/**
|
||||
* 求「Tab / Shift+Tab 该跳到哪一格」。
|
||||
*
|
||||
* 规则与底座的 Tab 导航**刻意不同**,这一点是核心:
|
||||
* · 底座(stk-table-vue 的 useAreaSelection)按「列 ±1」移动,落到哪列算哪列 ——
|
||||
* 它服务的是纯选区模型,没有「这一格能不能改」的概念;
|
||||
* · 行内编辑的语境里,只有**有编辑器、且这一行没被禁用**的格子才接得住焦点。
|
||||
* 底座的规则会把焦点送到只读格(`focusCol` 为 null),表现为「按 Tab 之后
|
||||
* 焦点凭空消失」;再按一次就掉到表格外面去(焦点无处可去,浏览器按文档顺序找下一个)。
|
||||
*
|
||||
* 所以这里在**扁平化后的可编辑格序列**上前进 / 后退:跳过不可编辑的格,
|
||||
* 行尾自动接到下一行第一个可编辑格(Shift+Tab 反向),也就是 Excel / AG Grid 的手感。
|
||||
*
|
||||
* `from` 必须是**已解析出的真实单元格**(`colIndex >= 0`):本函数只负责「往后 / 往前找」,
|
||||
* 不负责纠正一个未知位置 —— 当前位置的解析属于 DOM/编辑态的事,见 G3Table。
|
||||
*
|
||||
* 走到头时**循环回另一端**(Tab 是 Excel / AG Grid 的手感),而不是交给浏览器:
|
||||
* · 放行浏览器:焦点会被原生 Tab 序接管,而表格里**下一行的 checkbox 编辑器是可聚焦的**
|
||||
* (checkbox 常驻渲染,不像其它编辑器只挂在编辑行),一次空格就改了数据 ——
|
||||
* 比「不跳」危险得多;
|
||||
* · 原地不动:键盘用户被卡死(按 Tab 毫无反应)。
|
||||
* 循环不覆盖「全表一个可编辑格都没有」这种情况:那时返回 null,调用方放行原生 Tab
|
||||
* (没有可跳的目标,也就谈不上接管)。
|
||||
*/
|
||||
export function findAdjacentEditableCell<T extends TableRow>(
|
||||
rows: T[],
|
||||
columns: G3TableColumn<T>[],
|
||||
from: { rowIndex: number; colIndex: number },
|
||||
step: 1 | -1,
|
||||
): { rowIndex: number; colIndex: number } | null {
|
||||
const columnCount = columns.length
|
||||
const total = rows.length * columnCount
|
||||
if (!columnCount || total <= 0) return null
|
||||
|
||||
const isEditableAt = (index: number) => {
|
||||
const editor = columns[index % columnCount].editor as TableEditor<T> | undefined
|
||||
return Boolean(editor) && !isEditorDisabled(editor as TableEditor<T>, rows[Math.floor(index / columnCount)])
|
||||
}
|
||||
const toCell = (index: number) => ({
|
||||
rowIndex: Math.floor(index / columnCount),
|
||||
colIndex: index % columnCount,
|
||||
})
|
||||
|
||||
// 先顺着方向找;找不到再回绕扫另一头(含当前格:全表只有一格可编辑时就停在自己身上)
|
||||
const forwardStart = from.rowIndex * columnCount + from.colIndex + step
|
||||
for (let index = forwardStart; index >= 0 && index < total; index += step) {
|
||||
if (isEditableAt(index)) return toCell(index)
|
||||
}
|
||||
for (let index = step === 1 ? 0 : total - 1; index >= 0 && index < total; index += step) {
|
||||
if (isEditableAt(index)) return toCell(index)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* ---------- 编辑器 ---------- */
|
||||
|
||||
/**
|
||||
* 「这一格就是当前编辑格」的标记类(td 侧用 `:has(> …)` 命中)。
|
||||
*
|
||||
* 为什么需要它,而不是用 CSS 的 `:focus-within`:
|
||||
* **select 展开时焦点会被移进 Teleport 到 body 的下拉浮层**,那一刻 td 的
|
||||
* `:focus-within` 当场失效,编辑格的白底就退回库的选区底色(`--cs-bgc` 浅蓝)——
|
||||
* 表现是「select 编辑格的白底时有时无」,而 input 没有浮层、永远正常。
|
||||
*
|
||||
* 改由**内容节点**带标记(与「按行禁用的灰底」「校验态」同一套路子):
|
||||
* td 无法按行加类(库渲染 td 时 class 只取 `col.className`),但编辑器能,
|
||||
* 于是 `td:has(> .is-cell-editing)` 就能精确命中这一格,且不再随焦点在格内 / 浮层
|
||||
* / 短暂落到 body(换格的一瞬间)之间漂移。
|
||||
*
|
||||
* 三种编辑器都必须打这个类:漏掉任何一条,那种编辑器就会退回「时白时不白」。
|
||||
*/
|
||||
function activeCellClass<T extends TableRow>(
|
||||
ctx: EditableContext<T>,
|
||||
row: T,
|
||||
col: G3TableColumn<T>,
|
||||
is: (...states: (string | false | null | undefined)[]) => string,
|
||||
): string {
|
||||
return ctx.shouldFocus(row, col) ? is('cell-editing') : ''
|
||||
}
|
||||
|
||||
/** 编辑器到 G3Input 的 type 映射:fms 用 antdv 的 inputType,我们用自己的枚举 */
|
||||
const INPUT_TYPE_MAP: Record<string, 'default' | 'password' | 'number' | 'textarea'> = {
|
||||
text: 'default',
|
||||
@@ -331,7 +448,7 @@ const INPUT_TYPE_MAP: Record<string, 'default' | 'password' | 'number' | 'textar
|
||||
* 语义:
|
||||
* · `errorStyle: 'inline'` 时**两个类都不打**:inline 形态只要编辑器自身
|
||||
* 的内描边与文字色(见 types.ts 的 errorStyle 说明),
|
||||
* 铺底与角标是「单元格级」的表达,与 inline 的意图相反。
|
||||
* 铺底是「单元格级」的表达,与 inline 的意图相反。
|
||||
* · 其余情况按等级吐 `is-editor-error` / `is-editor-warning`。
|
||||
*
|
||||
* 注意这只是**视觉通道之一**。G3Input 还会另外收到 `status` prop,
|
||||
@@ -367,12 +484,12 @@ function createInput<T extends TableRow>(
|
||||
* 为什么要第二个通道(而不是直接让 CSS 盯 `.g3-input--error`):
|
||||
* `G3Select` **没有** status prop(只有 G3Input 有),select 编辑器
|
||||
* 拿不到 `--error` 类。若样式锚在 `.g3-input--error` 上,
|
||||
* 「select 列校验失败没有红底和角标」就成了必然。统一由本模块吐一个
|
||||
* 「select 列校验失败没有红底」就成了必然。统一由本模块吐一个
|
||||
* 自己的状态类,input / select / checkbox 三条路径才能用**同一个**选择器,
|
||||
* 表格样式也就不必知道 G3Input 的内部类名。
|
||||
*
|
||||
* `errorStyle: 'inline'` 时不再打这个类:inline 形态只要编辑器自身的内描边,
|
||||
* 不要铺底和角标(见 types.ts 里 errorStyle 的说明)。
|
||||
* 不要铺底(见 types.ts 里 errorStyle 的说明)。
|
||||
*/
|
||||
const status = resolveEditorStatus(editor, row, col)
|
||||
/**
|
||||
@@ -393,6 +510,7 @@ function createInput<T extends TableRow>(
|
||||
class: [
|
||||
e('cell-editor'),
|
||||
e('cell-editor-root'),
|
||||
activeCellClass(ctx, row, col, is),
|
||||
editorStatusClass(status, editor.errorStyle, is),
|
||||
],
|
||||
'aria-label': col.title || col.dataIndex,
|
||||
@@ -451,7 +569,7 @@ function createSelect<T extends TableRow>(
|
||||
/**
|
||||
* select 的校验态**只走类名**,不传 status prop —— G3Select 根本没有这个 prop
|
||||
* (只有 G3Input 有)。所以这里的 `is-editor-error` / `is-editor-warning`
|
||||
* 是 select 列拿到「铺底 + ! 角标」的唯一通道,不是 G3Input 那条路径的冗余。
|
||||
* 是 select 列拿到「铺底」的唯一通道,不是 G3Input 那条路径的冗余。
|
||||
* 详见 createInput 里 status 的注释与 editorStatusClass。
|
||||
*/
|
||||
const status = resolveEditorStatus(editor, row, col)
|
||||
@@ -473,6 +591,7 @@ function createSelect<T extends TableRow>(
|
||||
class: [
|
||||
e('cell-editor'),
|
||||
e('cell-editor-root'),
|
||||
activeCellClass(ctx, row, col, is),
|
||||
editorStatusClass(status, editor.errorStyle, is),
|
||||
],
|
||||
'aria-label': col.title || col.dataIndex,
|
||||
@@ -500,8 +619,7 @@ function createCheckbox<T extends TableRow>(
|
||||
/**
|
||||
* checkbox 也支持校验态。
|
||||
*
|
||||
* 它虽然不需要「给角标让位」的 padding(没有文字会被压住),
|
||||
* 但仍要参与「单元格亮红底」——否则同一行里 input 格报错、checkbox 格不报,
|
||||
* 它要参与「单元格亮红底」——否则同一行里 input 格报错、checkbox 格不报,
|
||||
* 用户会以为那一列没问题。统一给类,让表格侧的选择器只写一次。
|
||||
*/
|
||||
const status = resolveEditorStatus(editor, row, col)
|
||||
@@ -509,6 +627,9 @@ function createCheckbox<T extends TableRow>(
|
||||
const update = (value: number) => {
|
||||
row[col.dataIndex] = value as T[keyof T & string]
|
||||
notifyChange(ctx, editor, row, col, value)
|
||||
// 与点击其它格**行为一致**:这一格成为当前格(行高亮 / 选中框 / 编辑会话)。
|
||||
// 顺序放在「值已落库 + 已通知父级」之后:镜像先落,重渲染再发生。
|
||||
ctx.onEditorInteract?.(row, col)
|
||||
}
|
||||
return h(
|
||||
'label',
|
||||
@@ -516,6 +637,7 @@ function createCheckbox<T extends TableRow>(
|
||||
class: [
|
||||
e('checkbox'),
|
||||
e('cell-editor-root'),
|
||||
activeCellClass(ctx, row, col, is),
|
||||
{
|
||||
[is('checked')]: checked,
|
||||
// 用计算出的类名做 key,值为 false 时 Vue 会自动跳过
|
||||
@@ -565,9 +687,9 @@ export function createEditableCell<T extends TableRow>(
|
||||
) {
|
||||
const editor = col.editor as TableEditor<T>
|
||||
return ({ row }: { row: T }) => {
|
||||
if (isEditorDisabled(editor, row)) return displayValue(row, col, editor)
|
||||
if (isEditorDisabled(editor, row)) return displayValue(ctx, row, col, editor)
|
||||
if (editor.type === 'checkbox') return createCheckbox(row, col, editor, ctx)
|
||||
if (!ctx.isEditing(row)) return displayValue(row, col, editor)
|
||||
if (!ctx.isEditing(row)) return displayValue(ctx, row, col, editor)
|
||||
if (editor.type === 'select') return createSelect(row, col, editor, ctx)
|
||||
return createInput(row, col, editor, ctx)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user