diff --git a/code/g3soft-libs/docs/content/ui/data/table.md b/code/g3soft-libs/docs/content/ui/data/table.md
index 3afa1b17..c044a2c7 100644
--- a/code/g3soft-libs/docs/content/ui/data/table.md
+++ b/code/g3soft-libs/docs/content/ui/data/table.md
@@ -81,7 +81,7 @@ description: 高性能虚拟滚动表格,支持排序、固定列、多选、
## 行内编辑
-列上加 `editor` 即可编辑:**单击**单元格进入(不需要双击)。完整键位见下方「键盘:单击即编辑,方向键分工」。
+列上加 `editor` 即可编辑:**单击**选中单元格、**双击**(或 `Enter` / `F2` / 直接打字)进入编辑。完整键位见下方「两种状态:选中态与编辑态」。
### 跳格
@@ -103,33 +103,91 @@ Tab / Shift+Tab 只在**可编辑格**之间走动:没有 `editor` 的列、
| `select` | 用 [Select](/ui/form/select) 渲染;`options` 可以是数组,也可以是接收当前行的函数 |
| `checkbox` | 点击即切换,无需先激活整行;约定 `1` = 选中、`0` = 未选中(与后端 tinyint 对齐)。切换后**该格同时成为当前格**(行高亮 + 选中框 + 进入该行编辑),与点击其它单元格行为一致 |
-编辑器的输入值先写进内部草稿,**失焦才落库并抛出 `change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。
+编辑器的输入值先写进内部草稿,**提交时才落库并抛出 `change` / `value-change`**。这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。用 `editable={false}` 可整体禁用编辑。
-### 键盘:单击即编辑,方向键分工
+### 两种状态:选中态与编辑态
-**单击单元格即进入编辑**(挂上编辑器并自动聚焦,可以直接打字;同行切列时重新挂载并全选,输入即替换整格内容)。这是本组件的既有交互:少一次点击。
+表格里的键盘行为由**两种状态**决定,先认清状态,再看键位就不会困惑:
-在这个前提下,方向键必须**显式分工** —— 否则会全部被输入框吃掉,人在表格里走不动:
+| 状态 | 长什么样 | 怎么进入 | 说明 |
+| --- | --- | --- | --- |
+| **选中态** | 单元格有选中框,**格子里没有输入框** | 单击单元格、方向键 / `Tab` 移动 | 默认状态;此时方向键只挪选中框,**不会**进编辑 |
+| **编辑态** | 格子内有 `input` / `textarea`,正在改值 | 双击、`Enter`、`F2`、直接打字、`editRow()` | 方向键归输入框,到边界才放行换格 |
-| 按键 | 归谁 | 为什么 |
-| --- | --- | --- |
-| **左右** | 输入框(移动光标 / `Shift+左右` 选中文本) | 编辑文本的刚需:想改中间一位、想选中再覆盖 |
-| **上下**(单行) | **表格**(切到上 / 下一行的同一列,并进入编辑) | 单行输入框里上下键**没有任何原生用途**,让出去是零损失 |
-| `Shift+上下` | 输入框(选中到开头 / 结尾) | 原生的有效操作 |
-| **上下**(`textarea`) | 输入框(跨行移动光标) | 多行里那是有效操作 |
+一句话概括:**单击选中,双击编辑,打字覆盖;非编辑态方向键归表格,编辑态方向键先归输入框(到边界才放行)。**
-其它键:
+### 选中态键位
| 按键 | 行为 |
| --- | --- |
-| `Enter`(单行) | 确认 + 移到下一行同列(表尾即停,不循环) |
+| `↑` `↓` `←` `→` | 切换选中单元格(上下换行 / 左右换列),**不进入编辑** |
+| `Tab` / `Shift+Tab` | 向右 / 向左切换选中格(跳过只读列,行尾接下一行并可循环) |
+| `Enter` | 进入编辑态,**全选**内容(直接输入即替换整格) |
+| `F2` | 进入编辑态,**光标在末尾**(想接着改末尾时用它) |
+| 可打印字符(`a-z`、`0-9`、中文…) | 进入编辑态,**清空原值、只填入该字符**(Excel 的覆盖语义) |
+| `Esc` | 无操作 |
+
+`Space` 刻意**不算**可打印字符(它在表格里的通行语义是翻页 / 触发,也是复选框的切换键)。
+
+### 编辑态键位
+
+| 按键 | 行为 |
+| --- | --- |
+| `←` `→` | **先让输入框移动光标**;仅当**值为空**、或光标已在该方向**边界**时,才切到相邻单元格 |
+| `↑` `↓`(单行) | 提交当前格,切到上 / 下行的同列并**进入编辑** |
+| `Shift+↑/↓/←/→` | 归输入框(选中到首尾 / 收缩选区),不切格 |
+| `↑` `↓`(`textarea`) | 归输入框(跨行移动光标),不切格 |
+| `Tab` / `Shift+Tab` | 提交当前格,切到右 / 左单元格并**进入编辑**(跳过只读列、行尾接下一行、两端循环) |
+| `Enter`(单行) | 提交当前格,切到**下一行同列**并进入编辑(表尾即停,不循环) |
| `Enter`(`textarea`) | 换行(浏览器原生语义) |
-| `Esc` | 放弃本次编辑(回滚到进入前的值),并**把焦点还给该格** —— 可以接着用上下键走 |
-| `Tab` / `Shift+Tab` | 确认并跳到下一个**可编辑**格(跳过只读列与按行禁用的格、行尾自动接下一行、两端循环) |
-| `Space`(`checkbox`) | 切换勾选(原生 checkbox 的切换键);`Enter` 同样有效 —— 连续录入时手不用离开 Enter |
-| 失焦(点别处 / 用上下键走开) | **提交**并抛出 `change` |
+| `F2` | 光标移到末尾(已在编辑态时的合理响应) |
+| `Esc` | 取消编辑,**还原为原值**,回到选中态(焦点交给表格,可接着用方向键走) |
+| `Space`(`checkbox`) | 切换勾选(原生 checkbox 的切换键);`Enter` 同样有效 |
+| 失焦(点别处 / 走开) | **提交**并抛出 `change` |
+
+⚠️ **左右键的"边界才放行"是本组件最容易做错的一处**:若一律让给输入框,编辑态里就永远无法横向换格(只能靠 `Tab`,手感是"跳"而不是"逐格走");若一律给表格,就没法改金额中间那一位。判据因此取"输入框还能不能消费这一记按键"——有选区、或光标不在边界时归输入框,否则归表格。
+
+⚠️ 横向移动**不跨行**:在行末按 `→` 停在原地(要跨行请用 `Tab` / `Enter`)。纵向到表尾同理即停,不循环。
+
+⚠️ **`Esc` 对下拉 / 日历是两段式的**:`select` 展开时第一记 `Esc` 关下拉,第二记才取消整格编辑;`date` 面板同理(第一记关面板、第二记取消编辑)。这是刻意的——面板开着时用户按 `Esc` 想关的多半是面板,一次按键同时关面板又退编辑会让人措手不及。
+
+⚠️ `Esc` 取消对**所有**编辑器都有效,但"还原"的实现按值的存放位置分两类:`text` / `number` / `textarea` 的值在草稿里、提交前不曾通知父级,取消即丢弃草稿;`select` / `date` 的值是**立即写回并已抛过 `change`** 的,取消会把这一格改回进入时的值**并再抛一次 `change`**(父级据此回退,两侧数据不会分叉)。`Esc` 只回滚**当前这一格**,不会牵连同一行里先前改过的其它格。
+
+#### 输入法(IME)
+
+组合态(`event.isComposing` 或 `keyCode === 229`)下**一律不拦截任何键**。这是中文录入的硬要求:候选框上按 `Enter` 确认候选词时,`key` 同样是 `'Enter'`——不挡就会被当成"确认并下移",表现为**候选词上不了屏、光标还跳到了下一行**。同理候选框里的 `↑↓` 选字也不会被当成切行。
+
+### 鼠标
+
+| 操作 | 行为 |
+| --- | --- |
+| 单击 | **选中**单元格(进入选中态,不出现输入框) |
+| 双击 | **进入编辑态**,光标定位到双击处的字符 |
+| 单击已选中的单元格 | 保持选中,不进编辑 |
+| 点页面别处 | 退出编辑并**提交**当前格 |
+
+### 事件
+
+| 事件 | 触发时机 |
+| --- | --- |
+| `edit-start` | 进入编辑态**之后**(编辑器已挂载、已聚焦、光标已就位) |
+| `edit-end` | 编辑完成、值已提交(移动 / 失焦都算),随后离开这一格 |
+| `edit-cancel` | 取消编辑(`Esc`),值**已还原** |
+| `value-change` | 值实际发生变化(提交后新旧值不同) |
+| `change` | 与 `value-change` 同时抛出,语义完全相同(既有名字,保留兼容) |
+
+```vue
+ console.log('开始编辑', col.dataIndex)"
+ @edit-end="(row, col) => console.log('编辑结束', col.dataIndex)"
+ @edit-cancel="(row, col) => console.log('取消编辑', col.dataIndex)"
+ @value-change="(row, col, value) => console.log('值变了', value)"
+/>
+```
-⚠️ **左右键不能让给表格**:它一旦用来切列,就没法在输入框里移动光标 —— 同一个键不可能既移光标又切格。横向切换请用 `Tab`(它还会跳过只读列,比按列 ±1 更贴合录入语境)。
+⚠️ **`edit-start` 只在"用户刚激活这一格"时抛一次**,重渲染引起的重复聚焦不会再抛——否则按它做埋点会收到一串重复事件。同理 `edit-end` / `edit-cancel` 对同一次会话各只抛一次。
### 编辑器形态:单元格内无外间距、无常驻边框
diff --git a/code/g3soft-libs/docs/examples/table/editable.vue b/code/g3soft-libs/docs/examples/table/editable.vue
index 2e73d38c..a37e5738 100644
--- a/code/g3soft-libs/docs/examples/table/editable.vue
+++ b/code/g3soft-libs/docs/examples/table/editable.vue
@@ -3,7 +3,9 @@ import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
-// 行内编辑:列上加 editor 即可。单击单元格进入编辑,Enter 确认、Esc 取消。
+// 行内编辑:列上加 editor 即可。
+// 两种状态:单击**选中**单元格(不出现输入框);双击 / Enter / F2 / 直接打字进入编辑。
+// 编辑态下 Enter 提交并下移、Esc 取消还原、Tab 提交并跳格、上下键换行、左右键到边界才换格。
//
// 编辑器类型:
// text / number / password / textarea → 用 G3Input 渲染
@@ -17,7 +19,7 @@ import type { G3TableColumn, TableRow } from '@g3soft/ui'
// 传 :editor-seamless="false" 则回到「格内留一圈间距 + 自身描边」的悬浮卡片观感。
// 两种形态下编辑器的盒高都等于行高,切换不会改变行高、也不会让同行其它格跳动。
//
-// 关键行为:输入值先写进内部草稿,**失焦才落库并抛出 change**。
+// 关键行为:输入值先写进内部草稿,**提交时**才落库并抛出 change / value-change。
// 这样每敲一个字符不会触发父级重渲染(否则光标会丢、输入法会断)。
const columns: G3TableColumn[] = [
{ dataIndex: 'name', title: '名称', width: 200, editor: { type: 'text' } },
@@ -82,7 +84,8 @@ function onChange(row: TableRow, col: G3TableColumn, value: unknown) {
最近一次 change:{{ lastChange || '(点击单元格试试)' }}
- 单击进入编辑 · Enter 确认 · Esc 取消 · Tab / 方向键可换格 ·
+ 单击选中 · 双击(或 Enter / F2 / 直接打字)进入编辑 · 编辑态里 Enter 提交并下移 · Esc 取消 ·
+ Tab / 方向键换格 ·
单元格内无外间距、无常驻边框,焦点时才出现主色焦点环
diff --git a/code/g3soft-libs/packages/ui/e2e/table.spec.ts b/code/g3soft-libs/packages/ui/e2e/table.spec.ts
index 32a03ae1..4c34e6f5 100644
--- a/code/g3soft-libs/packages/ui/e2e/table.spec.ts
+++ b/code/g3soft-libs/packages/ui/e2e/table.spec.ts
@@ -1,11 +1,47 @@
import { expect, test } from '@playwright/test'
+import type { Locator } from '@playwright/test'
+
+/**
+ * 让某一格进入**编辑态**(双击 —— 规格 §四:单击只选中,双击才编辑)。
+ *
+ * 为什么抽成公共函数而不是每处写 `.click()`:进编辑的路径换了(单击 → 双击),
+ * 而用例里有几十处 `await cell.click()` 本意都是"进编辑"。集中成一个语义化的名字,
+ * 下次交互再变时只需改这一处,而不是靠 grep 猜哪些 click 是"进编辑"、哪些是"纯点击"。
+ *
+ * ⚠️ 不要改成两次 `click()`:Playwright 的 `dblclick` 会带上正确的 `detail: 2` 与
+ * 双击间隔,手写两次 click 在部分内核上不会被识别成双击。
+ */
+async function enterEdit(cell: Locator) {
+ await cell.dblclick()
+}
+
+/**
+ * 当前**被选中**的那一格(选中态)。
+ *
+ * 两态模型下,"哪一格是当前格"在选中态里由**库的选区**表达(`td[data-cs-s]`),
+ * 而不是焦点 —— 选中态下焦点在表格容器上(`td` 没有 tabindex、不可聚焦),
+ * 所以 `focusedCell` 那套读法在选中态里恒为 null。两个 helper 各管一个状态。
+ */
+async function selectedCell(page: import('@playwright/test').Page) {
+ return page.evaluate(() => {
+ const td = document.querySelector('td[data-cs-s]') as HTMLElement | null
+ if (!td) return null
+ return {
+ colKey: td.getAttribute('data-col-key'),
+ rowIndex: td.closest('tr')?.getAttribute('data-row-i') ?? null,
+ }
+ })
+}
/**
* G3Table 的几何 / 命中类断言 —— happy-dom 没有布局引擎,这些只能在真浏览器里量。
*
* 用例只按 data-testid 找元素(不依赖文案与结构),宿主页在 playground/main.ts。
+ *
+ * ⚠️ **进入编辑态用 `enterEdit`(双击),不要用单次 `.click()`**。
+ * 表格是**两态模型**:单击只选中(不出现输入框),双击 / `Enter` / `F2` / 打字才进编辑。
+ * 早先的用例普遍用 `.click()` 直接进编辑,那是旧的"单击即编辑"交互。
*/
-
test.describe('G3Table 表格', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
@@ -216,7 +252,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
const before = await rowHeights(page, '[data-testid="table-wrap"]')
- await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click()
+ await enterEdit(await cellByTitle(page, '[data-testid="table-wrap"]', '备注'))
const editor = wrap.locator(EDITOR).first()
await expect(editor).toBeVisible()
@@ -254,7 +290,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
- await (await cellByTitle(page, '[data-testid="table-wrap"]', '备注')).click()
+ await enterEdit(await cellByTitle(page, '[data-testid="table-wrap"]', '备注'))
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
const padding = await wrap
@@ -282,7 +318,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
const idle = await target.evaluate((el) => getComputedStyle(el).boxShadow)
expect(idle).not.toContain('inset')
- await target.click()
+ await enterEdit(target)
await expect(wrap.locator(EDITOR).first()).toBeVisible()
// 焦点环画在**被点的那一格**上(限定 target,避免匹配到同行其它格)
@@ -298,7 +334,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
const before = await rowHeights(page, '[data-testid="table-wrap"]')
// 双击进编辑(表格是「单击选中、进编辑另有其键」的两态模型,见 startEdit 的说明)
- await (await cellByTitle(page, '[data-testid="table-wrap"]', '状态')).click()
+ await enterEdit(await cellByTitle(page, '[data-testid="table-wrap"]', '状态'))
const select = wrap.locator('.g3-table__cell-editor-root.g3-select').first()
await expect(select).toBeVisible()
@@ -360,7 +396,7 @@ test.describe('G3Table 行内编辑:无外间距、无常驻边框', () => {
await wrap.scrollIntoViewIfNeeded()
const cell = await cellByTitle(page, '[data-testid="table-wrap"]', '状态')
- await cell.click()
+ await enterEdit(cell)
// 前置条件:浮层确实拿到了焦点,且焦点已不在表格里(这正是修前的失效条件)
await expect(page.locator('.g3-select__dropdown')).toBeVisible()
@@ -550,12 +586,12 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
await expect(cell.locator('.g3-table__cell-display')).toHaveText('¥12,000.00')
/**
- * ② 输入一个超精度的值,再点**另一个数据格**让它失焦提交。
+ * ② 输入一个超精度的值(双击进入编辑),再点**另一个数据格**让它提交并退出编辑。
*
* 这里不能点表头:编辑态的退出是由单元格选区变化驱动的,点表头不会退出编辑 ——
* 那样这一格还挂着编辑器,下面针对「展示态 span」的断言会直接找不到元素。
*/
- await cell.click()
+ await enterEdit(cell)
await cell.locator('input').fill('1.2345')
await wrap.locator('.stk-tbody-main tr').nth(2).locator('td[data-col-key="amount"]').click()
await page.waitForTimeout(200)
@@ -571,8 +607,8 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
*/
await expect(cell.locator('.g3-table__cell-display')).toHaveText('1.2345')
- // ⑤ 值也没被四舍五入:再点开编辑,输入框里还是用户敲的那串
- await cell.click()
+ // ⑤ 值也没被四舍五入:再双击回到编辑,输入框里还是用户敲的那串
+ await enterEdit(cell)
await expect(cell.locator('input')).toHaveValue('1.2345')
/**
@@ -624,15 +660,15 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
.evaluate((el) => getComputedStyle(el).paddingRight)
expect(await displayPad(1)).toBe(await displayPad(0))
- // ② 进入编辑:底色 = 基色(亮色主题即 #fff),只剩语义色焦点环
- await errCell.click()
+ // ② 进入编辑(双击):底色 = 基色(亮色主题即 #fff),只剩语义色焦点环
+ await enterEdit(errCell)
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)')
})
@@ -670,7 +706,7 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
expect(after).toBeGreaterThan(before)
})
- test('点击行进入编辑时,当前行高亮(active)跟随', async ({ page }) => {
+ test('单击可编辑格:当前行高亮(active)跟随', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-wrap"]')
await wrap.scrollIntoViewIfNeeded()
@@ -682,7 +718,13 @@ test.describe('G3Table 校验态 / 列宽拖拽 / 行高亮', () => {
wrap.locator('.stk-tbody-main tr.active').count()
expect(await activeCount()).toBe(0)
- // 点第 2 行的一个可编辑格(金额列),进入编辑并高亮
+ /**
+ * 单击第 2 行的一个可编辑格(金额列)→ 选中它并高亮该行。
+ *
+ * ⚠️ 单击**只选中**(两态模型),不进入编辑 —— 但当前行高亮必须照样跟上:
+ * 键盘不产生 click,调用方的 `@row-click` 收不到消息,所以"哪一行是当前行"
+ * 只能由组件在选区变化时同步(见 handleAreaSelectionChange 里那段注释)。
+ */
const ths = await wrap.locator('thead th').evaluateAll((all) =>
all.map((t) => (t.textContent || '').trim()),
)
@@ -961,9 +1003,9 @@ test.describe('G3Table 固定列 + 校验态', () => {
const firstRow = rows.first()
await expect(firstRow).not.toHaveClass(/active/)
- // 「名称(校验列)」是这张表里的可编辑列(带 editor),点它进入编辑
+ // 「名称(校验列)」是这张表里的可编辑列(带 editor),双击它进入编辑
const editableCell = await cellByTitleIn(page, WRAP, '名称(校验列)')
- await editableCell.click()
+ await enterEdit(editableCell)
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
// 当前行必须高亮 —— 这正是调用方靠 row-click 拿不到的那次点击
@@ -1015,7 +1057,7 @@ test.describe('G3Table 固定列 + 校验态', () => {
const firstRow = wrap.locator('.stk-tbody-main tr').first()
const nameCell = await cellByTitleIn(page, WRAP, '名称(校验列)')
- await nameCell.click()
+ await enterEdit(nameCell)
/**
* 编辑格:应当只有「一圈 2px 内描边」(焦点环),**不带**行状态的大范围叠色。
@@ -1101,7 +1143,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
await wrap.scrollIntoViewIfNeeded()
// 进入第一行「金额」格编辑(number 编辑器,焦点在原生 input 上)
- await wrap.locator('td[data-col-key="amount"]').first().click()
+ await enterEdit(wrap.locator('td[data-col-key="amount"]').first())
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' })
@@ -1137,7 +1179,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
await wrap.scrollIntoViewIfNeeded()
// 从第二行「金额」往回:先跨过只读的「操作」列落到上一行「备注」
- await wrap.locator('td[data-col-key="amount"]').nth(1).click()
+ await enterEdit(wrap.locator('td[data-col-key="amount"]').nth(1))
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '1' })
@@ -1169,7 +1211,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
- await wrap.locator('td[data-col-key="amount"]').first().click()
+ await enterEdit(wrap.locator('td[data-col-key="amount"]').first())
await expect(wrap.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'amount', rowIndex: '0' })
@@ -1204,7 +1246,7 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
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 enterEdit(big.locator('.stk-tbody-main tr[data-row-i="0"] td[data-col-key="name"]'))
await expect(big.locator('.g3-table__cell-editor-root.g3-input').first()).toBeVisible()
expect(await focusedCell(page)).toEqual({ colKey: 'name', rowIndex: '0' })
@@ -1229,21 +1271,26 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
* 用户实测 100% 复现的手势:在 ERP 里点「订舱号」(只读、没有编辑器)那一格,
* 然后一直按着 Tab 往右走,走到「起运港」时就掉出表格了。
*
- * 成因是两条叠加出来的同一个状态 ——「编辑会话在,但 DOM 里一个编辑器都没有」:
- * · 只读列起步:编辑态是 `editing=true` 而 `focusCol=null`,没有任何编辑器被挂载;
- * · 底座的**连按 / 长按优化**:处理 repeat 事件时会 `clearActiveEditing()`(把
- * `editing` 也置 false),长按期间刻意保持 0 编辑器,等松手再挂回来。
+ * 成因是两条叠加出来的同一个状态 ——「会话在,但 DOM 里一个编辑器都没有」:
+ * · 只读列起步:没有任何编辑器会被挂载;
+ * · 底座的**连按 / 长按优化**:处理 repeat 事件时会 `clearActiveEditing()`,
+ * 长按期间刻意保持 0 编辑器,等松手再挂回来。
* 旧判据只认 `editing === true`,于是从第 3 下起就认为「不是给表格的 Tab」而放行,
* 浏览器按文档顺序把焦点送出表格。
+ *
+ * ⚠️ 两态模型下这条路径落在**选中态**(点只读列只选中、不进编辑),
+ * 所以断言的是**选区**(`selectedCell`)而不是焦点 —— 选中态下焦点在表格容器上,
+ * 没有"聚焦在某格输入框"可言。要点不变:Tab 必须被接管、不许掉出表格。
*/
test('按住 Tab(autoRepeat)从只读列起步:一格一格前进,不掉出表格', async ({ page }) => {
await page.goto('/')
const wrap = page.locator(WRAP)
await wrap.scrollIntoViewIfNeeded()
- // 「名称」列没有 editor(对应 ERP 的「订舱号」),点它建立编辑会话
+ // 「名称」列没有 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)
+ await expect.poll(() => selectedCell(page)).toEqual({ colKey: 'name', rowIndex: '0' })
+ expect(await wrap.locator('.is-cell-editing').count()).toBe(0)
// 按住不放:Playwright 对**已按下**的键会把后续 keydown 标成 autoRepeat,与真实长按一致
await page.keyboard.down('Tab')
@@ -1251,26 +1298,27 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
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' })
+ // 四下 = 名称(只读) → 金额 → 状态 → 启用 → 备注(一格一格走,没有掉出表格)
+ await expect.poll(() => selectedCell(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` 也抛了,可整行不高亮、格子没有选中框,
+ * 节点被替换时不派发 click,不停就会变成「第一次点击进编辑、第二次才切换」)。
+ * 但停了之后底座收不到 mousedown,也就不会像点击其它格那样设选区、置当前行 ——
+ * 实测后果是:值改了、`change` 也抛了,可整行不高亮、格子没有选中框,
* 而且表格里**没有任何选区**,接着按 Tab 会从 (0,0) 起步、焦点掉到 `body`。
+ *
+ * ⚠️ 两态模型下点复选框与点其它格一致:**只选中**(`onEditorInteract` 把它设为当前格),
+ * 不再顺带激活整行编辑 —— 复选框自己始终可点,不需要编辑态。
*/
test('点复选框:一次点击即切换,且该格成为当前格(键盘可继续走)', async ({ page }) => {
await page.goto('/')
@@ -1286,14 +1334,13 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
// ① 一次点击就切换(这条是原本就正确的行为,别改坏)
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' })
+ await expect.poll(() => selectedCell(page)).toEqual({ colKey: 'remark', rowIndex: '0' })
})
})
@@ -1323,37 +1370,116 @@ test.describe('G3Table 键盘跳格:Tab / Shift+Tab', () => {
/**
* 方向键的分工(编辑器 vs 表格)。
*
- * 本组件的交互是**单击即进入行编辑**(点一格就能直接打字,少一次点击)。在这个前提下,
- * 方向键必须显式分工 —— 否则会全部被输入框吃掉,人在表格里根本走不动:
+ * 表格是**两态模型**(见 docs 的「两种状态:选中态与编辑态」)。方向键的归属跟着状态走:
*
- * · **左右** → 交给输入框移动光标 / `Shift+左右` 选中文本(「想改中间一位」的刚需);
- * · **上下** → 让给表格导航,切到上 / 下一行的同一列
- * (单行输入框里上下键**没有任何原生用途**,让出去是零损失);
- * · `Shift+上下` → 仍归输入框(原生语义是「选中到首尾」);
- * · `textarea` 里的上下 → 也归输入框(要跨行移动光标)。
+ * · **选中态**:方向键只挪选中框,不进编辑(本组前半段);
+ * · **编辑态**:`←` `→` **先归输入框**(有选区、或光标不在边界),
+ * 到边界 / 值为空才**交给表格切格**;`↑` `↓` 单行归表格(切上 / 下行同列并继续编辑);
+ * · `Shift+左右/上下` → 永远归输入框(选中文本 / 收缩选区,是有效操作);
+ * · `textarea` 里的 `↑↓` → 归输入框(跨行移动光标)。
*
* 宿主是 playground 的 `table-keys-wrap`:text / number / textarea 三列。
*/
-test.describe('G3Table 方向键分工:左右归输入框、上下给导航', () => {
- test('单击即进入编辑:挂上编辑器并自动聚焦', async ({ page }) => {
+test.describe('G3Table 方向键分工:边界放行、上下给导航', () => {
+ test('单击只选中:不挂编辑器、不聚焦输入框', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
await cell.click()
+
+ // 选中态:格子被选中(库的 data-cs-s),但**没有** input
+ await expect(cell).toHaveAttribute('data-cs-s')
+ await expect(cell.locator('input')).toHaveCount(0)
+ })
+
+ test('双击进入编辑:挂上编辑器并自动聚焦', async ({ page }) => {
+ await page.goto('/')
+ const wrap = page.locator('[data-testid="table-keys-wrap"]')
+ await wrap.scrollIntoViewIfNeeded()
+ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
+
+ await enterEdit(cell)
await expect(cell.locator('input')).toHaveValue('K-001')
await expect(cell.locator('input')).toBeFocused()
})
- test('上下键:切到上 / 下一行的同一列(并且进编辑,可以直接接着改)', async ({ page }) => {
+ /**
+ * 选中态下按 Enter 进编辑并**全选**(规格 §二)。
+ *
+ * 这是"打字即覆盖"的前置条件:全选之后敲任何字符都会替换整格内容。
+ */
+ test('选中态按 Enter:进入编辑并全选内容', async ({ page }) => {
+ await page.goto('/')
+ const wrap = page.locator('[data-testid="table-keys-wrap"]')
+ await wrap.scrollIntoViewIfNeeded()
+ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
+
+ await cell.click()
+ await page.keyboard.press('Enter')
+
+ const input = cell.locator('input')
+ await expect(input).toBeFocused()
+ // 全选:selectionEnd - selectionStart 等于文本长度
+ const selected = await input.evaluate((el) => {
+ const node = el as HTMLInputElement
+ return { len: node.value.length, span: (node.selectionEnd ?? 0) - (node.selectionStart ?? 0) }
+ })
+ expect(selected.span).toBe(selected.len)
+ })
+
+ /**
+ * 选中态按 F2 进编辑,**光标在末尾**(规格 §二)。
+ *
+ * 与 Enter 的差别就在插入符落点上:F2 是"接着改末尾",Enter 是"整体替换"。
+ */
+ test('选中态按 F2:进入编辑且光标在末尾', async ({ page }) => {
+ await page.goto('/')
+ const wrap = page.locator('[data-testid="table-keys-wrap"]')
+ await wrap.scrollIntoViewIfNeeded()
+ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
+
+ await cell.click()
+ await page.keyboard.press('F2')
+
+ const input = cell.locator('input')
+ await expect(input).toBeFocused()
+ const caret = await input.evaluate((el) => {
+ const node = el as HTMLInputElement
+ return { start: node.selectionStart, end: node.selectionEnd, len: node.value.length }
+ })
+ // 光标贴在末尾且没有选区(不是全选)
+ expect(caret.start).toBe(caret.len)
+ expect(caret.end).toBe(caret.len)
+ })
+
+ /**
+ * 选中态直接打字:进入编辑,**清空原值、只留这个字符**(规格 §二,Excel 的覆盖语义)。
+ */
+ test('选中态直接打字:进入编辑并只保留新字符(覆盖原值)', async ({ page }) => {
+ await page.goto('/')
+ const wrap = page.locator('[data-testid="table-keys-wrap"]')
+ await wrap.scrollIntoViewIfNeeded()
+ const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
+
+ await cell.click()
+ await page.keyboard.press('X')
+
+ const input = cell.locator('input')
+ await expect(input).toBeFocused()
+ // 原值 'K-001' 被覆盖,只剩用户敲的那个字符
+ await expect(input).toHaveValue('X')
+ })
+
+ test('上下键(编辑态):切到上 / 下一行的同一列,并继续编辑', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const rows = wrap.locator('.stk-tbody-main tr')
const first = rows.nth(0).locator('td[data-col-key="code"]')
- await first.click()
+ await enterEdit(first)
await page.keyboard.press('ArrowDown')
await expect(rows.nth(1).locator('td[data-col-key="code"] input')).toBeFocused()
@@ -1362,13 +1488,13 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航',
await expect(first.locator('input')).toBeFocused()
})
- test('左右键:在输入框内移动光标,不切格', async ({ page }) => {
+ test('左右键(编辑态,光标在中间):在输入框内移动光标,不切格', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="qty"]')
- await cell.click()
+ await enterEdit(cell)
const input = cell.locator('input')
await input.fill('12345')
@@ -1379,17 +1505,44 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航',
expect(after).toBe((before ?? 0) - 1)
await expect(input).toBeFocused()
- // 还在同一格:左右键没有冒到表格导航上
+ // 还在同一格:光标没到边界,左右键没有移交给表格
await expect(cell.locator('input')).toHaveCount(1)
})
+ /**
+ * 左右键的**边界放行**(规格 §三 核心原则):光标已贴在最右时按 `→`,
+ * 输入框再也动不了 → 这一记按键交给表格,切到右边那一格。
+ *
+ * 这是与旧行为(左右键一律归输入框、永不移交)最关键的一处差别:
+ * 少了它,编辑态里就没法用 `→` 逐格往右走。
+ */
+ test('左右键(编辑态,光标在边界):放行给表格,切到相邻格', async ({ page }) => {
+ await page.goto('/')
+ const wrap = page.locator('[data-testid="table-keys-wrap"]')
+ await wrap.scrollIntoViewIfNeeded()
+ const rows = wrap.locator('.stk-tbody-main tr')
+ const codeCell = rows.nth(0).locator('td[data-col-key="code"]')
+
+ await enterEdit(codeCell)
+ const input = codeCell.locator('input')
+ // 把光标放到末尾('K-001' → 位置 5)
+ await input.evaluate((el) => {
+ const node = el as HTMLInputElement
+ node.setSelectionRange(node.value.length, node.value.length)
+ })
+
+ // 末尾再按 → :交给表格,切到右列「数量」
+ await input.press('ArrowRight')
+ await expect(rows.nth(0).locator('td[data-col-key="qty"] input')).toBeFocused()
+ })
+
test('Shift+左右:留给输入框选中文本,不切格', async ({ page }) => {
await page.goto('/')
const wrap = page.locator('[data-testid="table-keys-wrap"]')
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="code"]')
- await cell.click()
+ await enterEdit(cell)
const input = cell.locator('input')
// 进入编辑时编辑器会**全选**内容(为「直接输入即替换」服务),所以先记下初始选区长度
const before = await input.evaluate((el) => {
@@ -1414,7 +1567,7 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航',
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="note"]')
- await cell.click()
+ await enterEdit(cell)
const textarea = cell.locator('textarea')
await textarea.fill('第一行')
await textarea.press('Enter')
@@ -1436,7 +1589,7 @@ test.describe('G3Table 方向键分工:左右归输入框、上下给导航',
* · Enter(多行)→ 换行(浏览器原生;多行与单行共用一段 onKeydown,早先没按类型分流,
* 按回车会把整段文本全选掉、根本换不了行)。
*
- * 方向键的分工(左右归输入框、上下给导航)在另一个 describe 里;宿主相同 ——
+ * 方向键的分工(边界放行、上下给导航)在另一个 describe 里;宿主相同 ——
* `table-wrap` 的列序被 Tab 跳格用例逐格锁着,而且它里面没有多行编辑器,
* 锁不住「单行 / 多行 Enter 语义不同」这条区分。
*/
@@ -1448,7 +1601,7 @@ test.describe('G3Table 键盘行为:Enter 与多行编辑器', () => {
const rows = wrap.locator('.stk-tbody-main tr')
const firstCell = rows.nth(0).locator('td[data-col-key="code"]')
- await firstCell.click()
+ await enterEdit(firstCell)
await firstCell.locator('input').fill('K-999')
await firstCell.locator('input').press('Enter')
await page.waitForTimeout(300)
@@ -1467,7 +1620,7 @@ test.describe('G3Table 键盘行为:Enter 与多行编辑器', () => {
await wrap.scrollIntoViewIfNeeded()
const cell = wrap.locator('.stk-tbody-main tr').first().locator('td[data-col-key="note"]')
- await cell.click()
+ await enterEdit(cell)
const textarea = cell.locator('textarea')
await textarea.fill('第一行')
await textarea.press('Enter')
@@ -1491,13 +1644,13 @@ test.describe('G3Table 日期编辑器(editor.type = date)', () => {
await expect(cell.locator('.g3-table__cell-display')).toHaveText('2026-03-15')
/**
- * ① 点格 → 自动聚焦 **且自动弹开日历面板**。
+ * ① 双击进编辑 → 自动聚焦 **且自动弹开日历面板**。
*
- * 这是 `type: 'date'` 的核心交互:点格的那次点击落在 `td` 上,触发不到 DatePicker
+ * 这是 `type: 'date'` 的核心交互:进编辑的点击落在 `td` 上,触发不到 DatePicker
* 自己的「点输入框才展开」,所以要由挂载钩子主动调它的 `openPanel()`
* (见 `utils/editable.ts` 的 openDatePanel)。手感与 ERP 里的日期格一致。
*/
- await cell.click()
+ await enterEdit(cell)
/**
* 焦点**落在面板里**(CalendarPanel 的日期格是 `