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 + +``` -⚠️ **左右键不能让给表格**:它一旦用来切列,就没法在输入框里移动光标 —— 同一个键不可能既移光标又切格。横向切换请用 `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 的日期格是 `